Chapter 1: Python Foundations
Essential Python programming for AI and ML: variables, data structures, control flow, functions, classes, data manipulation with NumPy/Pandas, and introduction to scikit-learn.
- Topics
- 9
- Videos
- 9
- Min read
- 80
Chapter Overview
Python is the dominant language for AI, machine learning, and computer vision. Its readable syntax, vast ecosystem of libraries, and active community make it the default choice for practitioners worldwide -- from academic research to production systems.
Before diving into algorithms, you need a solid foundation in Python's core features. You'll work with numerical data stored in arrays, text labels for classification, and dictionaries for configuration. You'll write functions to preprocess data and classes to build reusable components. Understanding these fundamentals makes the difference between struggling with syntax and focusing on the concepts that matter.
Why Python? Python offers:
- Rich ecosystem: NumPy, Pandas, OpenCV, PyTorch, TensorFlow, scikit-learn all have Python APIs
- Readability: Code is easy to write and understand
- Rapid prototyping: Quick iteration on ideas
- Large community: Extensive resources and support
What you'll learn in this chapter:
- Variables and Data Types -- How Python handles numbers, strings, booleans, and type annotations
- Lists and Collections -- Storing and manipulating sequences of data
- Dictionaries -- Key-value storage for structured data
- Control Flow -- Loops and conditionals for algorithm logic
- Functions -- Reusable code blocks, decorators, and functional patterns
- Classes -- Object-oriented design and the fit/predict pattern
- NumPy/Pandas Basics -- Fast numerical arrays and tabular data manipulation
- Data Loading -- Getting data from files into Python efficiently
- Scikit-learn Intro -- The consistent estimator API that powers most ML projects
Each topic builds toward writing production-quality code. Lists become feature arrays, dictionaries store model configs, loops iterate over batches, classes wrap models and datasets, and NumPy/Pandas power all numerical computation.
Chapter Roadmap
Click any topic to jump in
Variables & Data Types
Python's type system, mutability, and memory model — the building blocks everything else depends on.
Sequences and mappings
Lists & Collections
Ordered sequences, slicing, and comprehensions — the core data structures for processing datasets.
Dictionaries
Hash-based key-value storage — O(1) lookups that power feature maps, configs, and data pipelines.
Iteration and abstraction
Control Flow
Conditionals, loops, iteration patterns — controlling program execution for data processing and training loops.
Functions
Defining reusable logic with args, kwargs, lambdas — composing operations for clean ML pipelines.
Classes & Objects
Object-oriented patterns, inheritance, and special methods — the foundation of PyTorch modules and sklearn estimators.
NumPy, Pandas, and scikit-learn
NumPy/Pandas Basics
Vectorized computation and DataFrames — replacing Python loops with fast array operations.
Data Loading
Reading datasets efficiently with generators and memory mapping — handling data larger than RAM.
Scikit-learn Intro
The Estimator API, transformers, and pipelines — the standard interface for ML in Python.
Topics
9 topics in this chapter
You load a dataset, make a "copy" to experiment on, overwrite one value, and later discover that the original data changed too. Or two numbers that should be equal, 0.1 + 0.2 and 0.3, compare as unequal and a unit test fails. Neither bug produces an error message. Both come from not knowing exactly what a Python variable is and what type of value it refers to. Before you can write a training loop or a data pipeline, you need a precise picture of how names, objects, and types behave.
The previous chapter, Development Setup, gave you a working Python installation, an editor, and a virtual environment. This topic starts using them. Every later topic in this chapter, from lists to scikit-learn pipelines, is built out of the values introduced here.
We begin with variables as labels that point at objects, then cover the numeric types and their precision limits, strings, booleans and None, and type annotations that document intent. We finish with mutability, which explains the vanishing copy above and returns in every chapter that follows.
Definition
A Python variable is a name bound to an object. The object, not the name, carries a type such as int, float, str, bool or NoneType, and an identity reported by id(). Assignment binds a name without copying anything. An object is mutable if its contents can change in place, as with lists, dicts and NumPy arrays, and immutable otherwise, as with numbers, strings and tuples.
In this topic
- 1Variables as Labels
- 2Numeric Types
- 3Strings
- 4Booleans and None
- 5Type Annotations
- 6Mutable vs Immutable
Variables as Labels
Think of a variable as a sticky note with a name on it. You stick it on an object (a value in memory), and Python remembers where you put it. The key insight: the sticky note can be moved to a different object at any time.
x = 42 # x points to integer 42
x = "hello" # x now points to a string instead
Why this matters: When you write data = load_data(), you're not putting data "inside" a variable -- you're creating a label that points to data in memory. This explains why copy_data = data doesn't create a copy -- both labels point to the same data!
Python variables are pointers stored on the stack, each holding a reference (memory address) to a heap-allocated object. Assignment a = b copies the 8-byte pointer, not the object — regardless of object size. The id() function returns this address. Reference counting tracks how many pointers target each object; when the count hits zero, the object is freed. Circular references require the cyclic garbage collector, which runs in where is the number of objects in the cycle.
You load a dataset and modify a 'copy'. Original also changes! Why? data = load_data(); copy = data; copy[0] = 99
Numeric Types
Integers (int) are exact whole numbers with unlimited size -- Python handles arbitrarily large numbers automatically:
big = 10 ** 100 # 1 followed by 100 zeros!
Floats (float) are approximations using 64-bit "floating point" representation. They're fast but have tiny precision errors:
0.1 + 0.2 # 0.30000000000000004, not exactly 0.3
Complex numbers store both real and imaginary parts -- essential for Fourier transforms in signal processing and image analysis:
z = 3 + 4j # Represents point (3, 4) in the complex plane
abs(z) # 5.0 - the distance from origin
Python int uses arbitrary-precision arithmetic: internally it stores an array of 30-bit "digits," so a -bit integer uses machine words. Addition is , multiplication uses Karatsuba's algorithm at . Floats are IEEE 754 double-precision: 1 sign bit, 11 exponent bits, 52 mantissa bits, giving –16 significant decimal digits and a range up to . The classic arises because has no exact binary fraction representation.
A feature value 128 needs to be normalized to [0,1]. You write: normalized = 128 / 255. What type is the result?
Strings
Strings are immutable -- once created, they cannot be changed. This might seem limiting, but it makes strings safe to share and use as dictionary keys.
Why immutable? Imagine if you could change a filename string after using it as a dictionary key -- chaos! Instead, operations like replace() create entirely new strings.
path = "/data/train/sample_001.csv"
name = path.split("/")[-1] # "sample_001.csv"
base = name.replace(".csv", "") # Creates new string "sample_001"
f-strings embed expressions directly -- cleaner than concatenation:
f"Processing {name} ({n_samples} samples)"
Python strings are immutable arrays of Unicode code points stored as compact arrays (Latin-1, UCS-2, or UCS-4 depending on the widest character). Indexing is since each character occupies a fixed number of bytes within its encoding. Concatenating strings with + in a loop is total because each concatenation copies all previous characters into a new allocation. Use ''.join(parts) for concatenation. String hashing uses SipHash, computed lazily and cached — so using a string as a dict key is on first use but thereafter, where is the string length.
You have path '/data/train/sample_001.csv'. Extract: folder name, file name without extension, and the number.
Booleans and None
Booleans represent yes/no, true/false decisions. They're the foundation of all conditional logic:
is_valid = n_samples > 0 and n_features > 0
has_labels = target is not None
None is Python's way of saying "nothing here" -- it's not zero, not an empty string, but the explicit absence of a value:
result = None # "I don't have a result yet"
if result is None:
result = compute() # Now I do
Truthiness is Python's way of treating non-boolean values as true/false:
- Falsy (treated as False): 0, 0.0, "", [], {}, None
- Truthy (treated as True): Everything else
This lets you write if data: instead of if len(data) > 0:
bool is a subclass of int in Python: True == 1 and False == 0, so booleans participate directly in arithmetic. None is a singleton — there is exactly one None object in the interpreter, which is why is None (pointer comparison, ) is preferred over == None (which invokes __eq__, potentially for custom types). Truthiness evaluation calls __bool__() first, then __len__() as a fallback; NumPy arrays deliberately raise ValueError on __bool__ when they contain more than one element to prevent ambiguous truth testing.
Check if data loaded successfully: data = load_array(path). Why is if data: dangerous? What's correct?
Type Annotations
Python does not check types before running, so passing a list where a function expects a NumPy array fails only when the offending line executes, sometimes hours into a training job. Type annotations, standardized by PEP 484, write the expected types into the signature. In the formula, X: np.ndarray declares a parameter type and -> Model the return type. The interpreter stores annotations in __annotations__ but never enforces them. Static checkers such as mypy, and Pylance from Development Setup, read them and flag mismatches as you type. Generic forms such as list[int] and tuple[np.ndarray, np.ndarray] describe containers, and Optional[np.ndarray] marks a value that may be None, from Booleans and None.
Type annotations are stored in __annotations__ dictionaries at function and module level but have zero runtime cost — CPython ignores them during execution. Static analyzers like mypy perform Hindley-Milner-style type inference extended with gradual typing: annotated code is checked at in the size of the AST, while unannotated functions are treated as Any. Generic types like list[int] use parametric polymorphism, and TypeVar enables bounded quantification: T = TypeVar('T', bound=Comparable) restricts to types with ordering.
Write a function signature with type hints: function predict takes a numpy array X and returns a numpy array of predictions.
Mutable vs Immutable
Lists are mutable (can change), while tuples and strings are immutable. NumPy arrays are mutable but have fixed size after creation. Understanding mutability is critical for avoiding subtle bugs when copying data.
# Mutable: changes affect all references
a = [1, 2, 3]
b = a # b points to same list
b[0] = 99 # a is now [99, 2, 3] too!
# Immutable: safe from accidental changes
t = (1, 2, 3)
# t[0] = 99 # TypeError!
Immutable objects can be safely shared across references because no mutation is possible — this is why Python interns small integers ( to ) and short strings, reusing the same object for identical values. Mutable objects like lists use a dynamic array internally: append() is amortized because the array doubles capacity when full (), giving a geometric growth pattern. Copying a list with a.copy() is — it copies the pointer array but not the underlying elements (shallow copy). copy.deepcopy() recursively copies all nested objects at where is the total object graph size.
What happens? a = [1, 2, 3]; b = a; b[0] = 99; print(a)
Theory Exercise
Problem:
What will be printed? Explain each step.
a = 10
b = a
a = 20
print(b)
Hints:
- Variables are references to objects, not containers
- Assignment creates a new reference
- Integers are immutable in Python
Coding Exercise
Problem:
Create a dictionary that maps model names to their hyperparameter dictionaries.
Hints:
- Use nested dictionaries
- Include at least 2 models
Related Problems on PixelBank
A classifier needs 50,000 training images, their labels, and a record of the loss after every epoch. Storing each value in its own variable is impossible, so you need one object that holds many values in order, grows as results arrive, and lets you pull out the first ten, the last ten, or every fifth. Choosing the wrong container also costs time: checking membership in a million-element list can be thousands of times slower than in a set.
The previous topic, Variables & Data Types, showed that names point to objects and that some objects are mutable. Lists are the first mutable container you will use heavily, so the aliasing rules from that topic apply directly.
We start with lists as ordered, growable sequences and the cost of appending to them. Then we cover indexing and slicing with the half-open stop rule, and list comprehensions, the compact way to build and filter lists. We close with tuples for fixed records and sets for fast membership tests and deduplication.
Definition
A list is an ordered, mutable sequence of references to objects, indexed from 0 to n − 1, that grows at the end in amortized constant time. A slice lst[start:stop:step] returns a new list of the elements from start up to but not including stop. A tuple is an immutable ordered sequence, and a set is an unordered collection of unique, hashable elements with average constant-time membership tests.
In this topic
- 1Lists: Ordered Mutable Sequences
- 2Indexing and Slicing
- 3List Comprehensions
- 4Tuples and Sets
Lists: Ordered Mutable Sequences
Think of a list as a numbered row of boxes. Each box can hold anything, and you can add, remove, or rearrange boxes whenever you want.
values = [128, 64, 255, 32] # Row of values
values[0] = 100 # Change what's in box 0
values.append(50) # Add new box at the end
values.pop() # Remove the last box
Why lists matter: Data in AI and ML is fundamentally sequences -- lists of samples, lists of features, lists of predictions. Understanding list operations is understanding data manipulation at its core.
Python lists are dynamic arrays: internally a contiguous C array of PyObject* pointers. Random access is since lst[i] computes base_addr + i * 8. append() is amortized due to over-allocation — when the array fills, Python grows it by roughly plus a constant. insert(0, x) is because every pointer must shift right. pop() from the end is ; pop(0) is . For frequent front operations, collections.deque provides at both ends using a doubly-linked list of fixed-size blocks.
Build a batch of data samples: start empty, add samples one by one. What's wrong with batch = batch + [sample]?
Indexing and Slicing
Zero-based indexing: The first element is at position 0, not 1. This matches how memory works and is universal in programming.
Negative indices count from the end: -1 is the last element, -2 is second-to-last.
lst = [10, 20, 30, 40, 50]
lst[0] # 10 - first element
lst[-1] # 50 - last element
lst[1:4] # [20, 30, 40] - elements 1, 2, 3 (stop is exclusive!)
lst[::2] # [10, 30, 50] - every 2nd element
lst[::-1] # [50, 40, 30, 20, 10] - reversed
The exclusive stop rule: lst[1:4] means "start at 1, go up to (but not including) 4". This makes len(lst[a:b]) == b - a.
Slicing lst[a:b] creates a new list by copying pointers, so it is in time and space. The exclusive-stop convention ensures and that lst[:k] + lst[k:] reconstructs the original list for any . Negative indexing is syntactic sugar: lst[-i] maps to lst[len(lst) - i] at . Stride slicing lst[::s] selects every -th element, producing elements. NumPy arrays differ fundamentally: slices return views (shared memory) rather than copies, making them but requiring caution with mutation.
A dataset has 100 samples. Get: first 10, last 10, every 5th sample, samples in reverse order.
List Comprehensions
List comprehensions are compact for-loops that build lists. Read them as: "give me [expression] for each [item] in [collection] where [condition is true]."
# "Give me x squared for each x in values"
squared = [x**2 for x in values]
# "Give me x for each x in values where x > 128"
filtered = [x for x in values if x > 128]
# Nested: "for each row, for each element in that row"
flat = [elem for row in matrix for elem in row]
Why use them? They're often 2-3x faster than equivalent for-loops because Python optimizes them internally.
List comprehensions compile to dedicated LIST_APPEND bytecodes that bypass the overhead of list.append() method lookups, making them – faster than equivalent for loops. Memory usage is for the output list. For large datasets, generator expressions (expr for x in iterable) use memory by yielding items lazily. Nested comprehensions [f(x) for row in matrix for x in row] execute left-to-right — the outer loop runs first. The time complexity of a filtered comprehension [x for x in data if p(x)] is where is the cost of the predicate.
You have a list of file paths. Load only .csv files and preprocess each. One line?
Tuples and Sets
Tuples are "frozen lists" -- once created, they can't be changed. This makes them safe to use as dictionary keys or function returns:
shape = (1000, 100) # Fixed: samples, features
n_samples, n_features = shape # Unpack into separate variables
Sets are like bags where duplicates automatically disappear. They're blazingly fast for "is this item in the collection?" checks:
unique_labels = {'cat', 'dog', 'bird'}
'cat' in unique_labels # True - O(1) instant!
When to use which?
- List: ordered, may have duplicates, need to modify
- Tuple: ordered, fixed data, need as dict key
- Set: unordered, unique values, fast membership tests
Tuples are immutable fixed-size arrays — their memory is allocated in a single block of bytes (for elements), slightly smaller than lists which need extra space for growth. Because tuples are immutable and hashable (when all elements are hashable), they can serve as dictionary keys. Sets use a hash table with open addressing: membership testing x in s is amortized. Set operations exploit hash-based algorithms: intersection is , union is , and difference is . The load factor is kept below ; exceeding it triggers a resize to or capacity.
You need to count unique class labels in a dataset with 1M samples and many duplicates. Fastest way?
Theory Exercise
Problem:
Given matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] representing a 3x3 matrix, write expressions to get: (a) The center element (b) The first row (c) The last column as a list
Hints:
- matrix is a list of lists (rows)
- matrix[row][col] for element access
- Use list comprehension for the column
Coding Exercise
Problem:
You have a list of raw sensor sample readings (floats), some negative due to noise. Build a cleaned dataset: keep only non-negative samples using a list comprehension, then report the count, min, max, and the set of unique rounded values. Also pair each kept sample with its index using a tuple.
Hints:
- Use a list comprehension with an `if` filter: `[x for x in samples if x >= 0]`.
- `min()`, `max()`, and `len()` work directly on the filtered list; build a set with `{round(x) for x in cleaned}`.
- Use `enumerate(cleaned)` inside a comprehension to produce `(index, value)` tuples.
Related Problems on PixelBank
A training script has to know its learning rate, batch size, and optimizer, map the class name "cat" to the integer 0 and back again, and count how many examples each class has. With lists you would search position by position for every lookup, and the cost grows with the number of entries. What you want is to go straight from a key, such as a name, to its value in about the same time however many entries there are.
The previous topic, Lists & Collections, stored values by position and introduced sets, which answer membership questions by hashing. A dictionary uses the same hashing idea but attaches a value to every key.
We start with dictionary basics and why lookup is fast, then the methods that make access safe, such as get and items. Dictionary comprehensions build label mappings in one line. Nested dictionaries and defaultdict then handle grouped and hierarchical data such as configs, datasets split into train and test, and per-class results.
Definition
A dictionary is a mutable mapping from unique, hashable keys to arbitrary values. It stores entries in a hash table, so lookup, insertion, and deletion take constant time on average, and since Python 3.7 it preserves insertion order. Reading a missing key with square brackets raises KeyError, while d.get(key, default) returns the default instead.
In this topic
- 1Dictionary Basics
- 2Dictionary Methods
- 3Dictionary Comprehensions
- 4Nested Dictionaries
Dictionary Basics
Think of a dictionary as a lookup table -- like a real dictionary where you look up a word (key) to find its definition (value). The magic: finding any entry is nearly instant, no matter how large the dictionary.
config = {
'learning_rate': 0.001,
'batch_size': 32,
'epochs': 100,
'optimizer': 'adam'
}
config['learning_rate'] # 0.001 - instant lookup!
config['dropout'] = 0.5 # Add new entry
Why O(1)? Dictionaries use "hashing" -- turning keys into memory addresses. Like knowing exactly which shelf a book is on rather than searching every shelf.
Python dicts use open addressing with a compact hash table. Lookup is amortized, but worst-case with hash collisions. The load factor triggers resizing at capacity, growing to or the current size. Since Python 3.7, dicts preserve insertion order by storing entries in a dense array with a separate sparse index table — this reduces memory by compared to the old design. The hash function for integers is the identity (for small values), while strings use SipHash for security against hash-flooding attacks.
Store metadata for detected objects. Each entry has: x, y, width, height, label, confidence. Best structure?
Dictionary Methods
The safe way to access: get() returns a default value if the key doesn't exist, avoiding crashes:
dropout = config.get('dropout', 0.5) # Returns 0.5 if 'dropout' missing
# vs config['dropout'] which would crash!
Three ways to iterate:
for key in config.keys(): # Just the keys
for val in config.values(): # Just the values
for k, v in config.items(): # Key-value pairs together
print(f"{k}: {v}")
Membership check: if 'learning_rate' in config: is O(1) -- instant, no matter the dict size.
dict.get(key, default) is average — it computes the hash, probes the table, and returns the default if the slot is empty, avoiding the exception overhead of KeyError. dict.items() returns a view object backed by the same underlying data — iterating it is but creating the view is . The in operator on a dict checks keys only (not values) and is amortized. dict.update(other) merges in where is the size of other, and Python 3.9+ supports d1 | d2 merge syntax which creates a new dict in .
Model config dict might or might not have 'dropout' key. If missing, use 0.5. If present but None, also use 0.5. Cleanest way?
Dictionary Comprehensions
Like list comprehensions, but build dictionaries. Read as: "for each item, create a key-value pair."
# Build a histogram: "for each unique value, count how many times it appears"
counts = {val: data.count(val) for val in set(data)}
# Flip a mapping: swap keys and values
label_codes = {'cat': 0, 'dog': 1, 'bird': 2}
code_labels = {v: k for k, v in label_codes.items()}
# Result: {0: 'cat', 1: 'dog', 2: 'bird'}
Common use: Building label-to-index mappings for classification.
Dict comprehensions {k: v for ...} pre-size the hash table when the input length is known, avoiding repeated resizing. Building a histogram via {val: data.count(val) for val in set(data)} is where is the number of unique values, because list.count() is per call. The efficient alternative is a single-pass loop with dict.get() at total, or collections.Counter(data) which is implemented in C and even faster. Inverting a dict {v: k for k, v in d.items()} is but only produces a valid inverse if all values are unique and hashable.
Dataset has class names: ['cat', 'dog', 'bird']. Create mapping from name→index and index→name for training.
Nested Dictionaries
Dictionaries can contain other dictionaries -- perfect for hierarchical data like datasets or configs:
dataset = {
'train': {'features': [...], 'labels': [...]},
'test': {'features': [...], 'labels': [...]}
}
train_features = dataset['train']['features'] # Drill down
defaultdict solves a common annoyance: normally, accessing a missing key crashes. With defaultdict, missing keys auto-create with a default value:
from collections import defaultdict
histogram = defaultdict(int) # Missing keys become 0
for value in data:
histogram[value] += 1 # No KeyError!
Nested dicts form a tree structure where each level of nesting adds one hash-table lookup. Accessing d[k1][k2][k3] performs three lookups for total. defaultdict(list) overrides __missing__ to auto-create entries, using the factory function to construct the default value — this is per missing-key access. Memory overhead compounds with nesting: each dict object costs bytes (empty) plus bytes per entry. For deep hierarchies, consider json-style access patterns or dataclasses to reduce overhead. ChainMap provides lookup across dicts without merging them.
Group detected objects by class: {'car': [box1, box2], 'person': [box3]}. Build this from flat list of entries.
Theory Exercise
Problem:
Write code to count the frequency of each value in a list of data points.
Hints:
- Use a dictionary to map value -> count
- Iterate through data and increment counts
- Consider using dict.get() for safe access
Coding Exercise
Problem:
Given a short corpus string, build a word-frequency dictionary (lowercased, punctuation-stripped). Then use dict methods and a dict comprehension to report the most frequent word and a filtered dict of words that appear more than once. Finally nest these stats under a small summary dictionary.
Hints:
- Normalize each word with `.lower().strip('.,!?')`, then count with `counts[w] = counts.get(w, 0) + 1`.
- Find the top word with `max(counts, key=counts.get)`; iterate pairs with `counts.items()`.
- Build the filtered dict with a comprehension: `{w: c for w, c in counts.items() if c > 1}`.
Related Problems on PixelBank
A training script is not a straight list of instructions. It must repeat the same update for every batch in every epoch, stop early when the validation loss stops improving, skip corrupted samples, and treat a confident prediction differently from an uncertain one. Straight-line code cannot express any of that. You need ways to choose between branches and to repeat work either a fixed or an open-ended number of times.
The previous topic, Dictionaries, gave you containers of keyed data, and the one before it gave you lists. Control flow is how you walk through those containers and make a decision about each element.
We start with conditional statements and the one-line conditional expression. For loops then iterate over any iterable, with zip pairing samples and labels. While loops handle open-ended repetition, with break and continue to exit or skip. Finally, range and enumerate supply the counters and indices that loops so often need. The animation below steps through a loop one iteration at a time.
Definition
Control flow is the order in which statements execute. An if/elif/else chain runs the first branch whose condition is truthy. A for loop runs its body once per item of an iterable, a while loop repeats as long as its condition stays truthy, break exits the innermost loop, and continue skips to that loop's next iteration.
In this topic
- 1Conditional Statements
- 2For Loops
- 3While Loops and Control
- 4Range and Enumerate
Conditional Statements
Programs constantly need to act differently depending on the data: bucket a score, reject an empty file, or pick a learning-rate schedule. In the formula, if tests a condition, each elif is tried only when every earlier test failed, and else catches everything left over. Python evaluates the conditions from top to bottom and runs exactly one branch, so order matters. Test score > 0.9 before score > 0.5, or the high bucket is never reached. Any value can serve as a condition through the truthiness rules from Variables & Data Types. The conditional expression a if cond else b covers simple two-way choices in a single line.
category = 'high' if score > 0.9 else 'medium' if score > 0.5 else 'low'
Python evaluates if/elif/else chains sequentially — each branch is tested in order, so the worst case for branches is condition checks. Short-circuit evaluation in and/or skips the right operand when the result is determined: False and expensive() never calls expensive(). The ternary x if cond else y compiles to a single JUMP_IF_FALSE bytecode followed by a POP — it is not syntactic sugar for an if-statement but a genuine expression that evaluates to a value. Match-case (Python 3.10+) uses a sequence of pattern checks, currently for patterns, though future optimizations may use jump tables.
Categorize image by aspect ratio: 'portrait' if height > width, 'landscape' if width > height, else 'square'. One line?
For Loops
Python's for loop has no counter to manage. In the formula, x is a name rebound to each element of the iterable in turn, and the iterable can be a list, a string, a dict (yielding keys), a file (yielding lines), or a generator. Under the hood the loop calls next on an iterator until it raises StopIteration. enumerate adds a running index, and zip walks several iterables in lockstep, pairing samples with labels. The classic failure is mismatched lengths, because zip stops at the shortest input without warning. Adding or removing list elements while iterating over that list also skips elements.
for i, (sample, label) in enumerate(zip(samples, labels)):
train_step(sample, label)
Python's for loop calls iter() on the iterable (invoking __iter__), then repeatedly calls __next__() until StopIteration is raised. Each iteration has ns overhead from the interpreter loop. enumerate(iterable) wraps the iterator and maintains a counter with zero additional memory beyond an integer. zip(a, b) interleaves calls to next() on both iterators, stopping at the shortest — its overhead is . For performance-critical inner loops, pushing work into NumPy vectorized operations eliminates the per-iteration Python overhead entirely.
Process samples with their labels. Lists: samples (1000 items), labels (999 items - one missing!). What happens with zip?
While Loops and Control
Some loops cannot know their length in advance: optimize until the error drops below a tolerance, or read a stream until it ends. A while loop re-tests its condition before every pass and stops once the condition becomes falsy. If the body never changes what the condition depends on, the loop never ends, so give every while a guaranteed exit such as an iteration cap. Inside any loop, break leaves the innermost loop immediately and continue jumps to the next pass. A loop can also carry an else block, which runs only when the loop finished without a break, a compact way to say "not found".
it = 0
while error > tol and it < 1000:
params, error = step(params)
it += 1
while loops check a condition before each iteration — each check involves evaluating the condition expression and a POP_JUMP_IF_FALSE bytecode. break compiles to BREAK_LOOP, which unwinds the block stack to the nearest loop. continue jumps directly to the condition check. The while True: ... break pattern is common for "loop-and-a-half" problems where the exit condition is computed mid-body. Convergence loops like while error > tol terminate when the error sequence decreases below the tolerance — for gradient descent with learning rate , this takes iterations for convex objectives.
Read data samples until you find one meeting a condition, or reach end of stream. Return the sample or None.
Range and Enumerate
Many loops need integers rather than elements: repeat for 10 epochs, visit every tenth index, or count backwards. In the formula, range(start, stop, step) produces the integers from start up to but not including stop, moving by step, which may be negative. It is lazy, so range(10**9) uses almost no memory. The half-open stop matches list slicing from Lists & Collections, so range(n) yields exactly n values, one valid index per element of a list of length n. enumerate(items, start=0) pairs each element with its position and replaces the error-prone for i in range(len(items)). A classic off-by-one is range(1, n), which skips index 0.
list(range(9, -1, -3)) # [9, 6, 3, 0]
range(start, stop, step) is a lazy sequence object that computes elements on demand using memory regardless of the range size. Membership testing x in range(...) is also — it checks and arithmetically rather than iterating. range supports negative steps for reverse iteration. enumerate(iterable, start=0) produces tuples with overhead per iteration. The length of range(a, b, s) is .
Iterate over matrix elements at (row, col) positions. Matrix is 480x640. Access pattern for processing?
Theory Exercise
Problem:
Write code that processes a 2D matrix (list of lists) and returns the (row, col) coordinates of all elements above a threshold.
Hints:
- Use nested for loops to iterate rows and columns
- enumerate() gives you both index and value
- Store coordinates as tuples in a list
Coding Exercise
Problem:
Simulate a tiny training loop. Iterate over a list of per-epoch loss values with enumerate; use a conditional to label each epoch as 'improved' or 'worse' versus the previous loss, and use break to early-stop when the loss drops below a target threshold. Track and print the epoch where training stopped.
Hints:
- Use `for epoch, loss in enumerate(losses):` to get both the index and the value.
- Compare against the previous loss with an `if`; use `continue` to skip the very first epoch's comparison.
- Use `break` once `loss < target`, and remember the stopping epoch in a variable.
Related Problems on PixelBank
Suppose your preprocessing code, which resizes, normalizes, and clips, is pasted into the training script, the evaluation script, and a notebook. You fix a bug in one copy, and the other two keep producing different numbers, so the model you evaluate is no longer the model you trained. Duplicated logic is one of the most common sources of irreproducible ML results. The cure is to write the logic once, give it a name, and call it everywhere.
The previous topic, Control Flow, gave you the loops and branches that make up a function body. This topic packages them. Functions also interact with the mutability rules from Variables & Data Types, which is where one of Python's most famous bugs comes from.
We begin with defining functions and returning values, then default and keyword arguments, including the mutable-default trap. *args and **kwargs handle variable argument lists, lambdas cover throwaway one-liners such as sort keys, and pure functions, which never modify their inputs, keep preprocessing reproducible.
Definition
A function is a named, reusable block of code defined with def. Calling it binds its parameters to the argument objects without copying them, runs the body in a fresh local scope, and returns the value of the first return statement reached, or None if there is none. Default argument values are evaluated once, when the def statement runs, not on each call.
In this topic
- 1Defining Functions
- 2Arguments and Defaults
- 3*args and **kwargs
- 4Lambda Functions
- 5Pure Functions
Defining Functions
A function is a named recipe -- give it inputs, it gives you an output. Once defined, you can reuse it anywhere without rewriting the logic.
def preprocess(data, scale):
"""Preprocess data by applying scale factor.""" # Docstring explains purpose
n_samples = len(data)
scaled = [x * scale for x in data]
return scaled
Why functions matter: They make code reusable, testable, and readable. Instead of repeating the same 10 lines everywhere, call preprocess() once.
Python functions are first-class objects: def creates a function object and binds it to the name in the local namespace. The function body is compiled to bytecode once at definition time and stored in func.__code__. Each call creates a new stack frame ( bytes), binds arguments, and executes the bytecode. Function call overhead is –ns in CPython. return pops the frame and passes the result to the caller. Returning multiple values as a tuple is for values — Python does not copy the values, just creates a tuple of references.
Function that returns both processed data AND processing time. How to return two values?
Arguments and Defaults
Default arguments let callers skip common options. Required arguments come first, optional ones with defaults come last.
def train_model(X, y, epochs=100, learning_rate=0.01, verbose=True):
# epochs, learning_rate, verbose have sensible defaults
...
# All these work:
train_model(X, y) # Use all defaults
train_model(X, y, 200) # Override epochs
train_model(X, y, learning_rate=0.001) # Override just lr
train_model(X, y, epochs=200, learning_rate=0.001) # Override both
Design tip: Good defaults make common cases simple while keeping rare cases possible.
Default argument values are evaluated once at function definition time and stored in func.__defaults__. This is why mutable defaults (lists, dicts) are shared across calls — each invocation sees the same object. The standard fix def f(x=None): if x is None: x = [] evaluates the default at call time. Keyword arguments are matched by name using a dict-based lookup internally. *args packs extra positionals into a tuple ( to construct), and **kwargs packs extra keywords into a dict (). Argument unpacking f(*args, **kwargs) is also .
Common bug: def add_sample(samples=[]): then samples.append(sample). Call twice -- second call sees first call's sample! Why?
*args and **kwargs
Sometimes you don't know in advance how many arguments you'll receive. *args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dictionary.
def compose(*transforms): # Accept any number of transforms
"""Chain multiple data transforms."""
def combined(data):
for t in transforms:
data = t(data)
return data
return combined
# Use it:
pipeline = compose(normalize, scale, augment)
result = pipeline(data)
Real-world use: Wrapper functions that pass arguments through to other functions (decorators, API wrappers). sklearn uses **kwargs extensively to pass parameters to base classes.
*args collects excess positional arguments into a tuple — since tuples are immutable, this is safe to store without copying. **kwargs collects excess keyword arguments into a regular dict. When forwarding with func(*args, **kwargs), Python unpacks the tuple and dict back into the function's parameter slots. The total cost is where is the number of arguments. This pattern enables the Decorator pattern: a wrapper that accepts any signature can transparently forward all arguments. The functools.wraps decorator preserves the original function's __name__ and __doc__ on the wrapper.
Write wrapper that logs when any function is called, but passes through all arguments unchanged to original function.
Lambda Functions
Lambdas are throwaway functions -- tiny, unnamed functions for one-off use. Think of them as inline expressions.
# Sort results by score (without defining a named function)
results.sort(key=lambda r: r['score'])
# Lambda: lambda r: r['score']
# Means: "given r, return its score"
When to use lambdas: Only for simple, one-line operations. For anything complex, define a proper function -- your future self will thank you.
# Prefer this for readability:
normalized = [x / 255.0 for x in values]
# Over this:
normalized = list(map(lambda x: x / 255.0, values))
Lambda expressions compile to the same MAKE_FUNCTION bytecode as def — they produce identical function objects with the same call overhead. The only difference is syntactic: lambdas are limited to a single expression (no statements, no assignments). Lambdas create closures over enclosing variables, capturing them by reference, not by value — the classic loop pitfall [lambda: i for i in range(5)] creates five functions all referencing the final value of i. The fix is a default argument: lambda i=i: i. In terms of complexity, a lambda passed to sorted(key=...) is called times during the sort.
Sort results by confidence (highest first), then by area (largest first) as tiebreaker. One line?
Pure Functions
Functions that always return the same output for the same input, with no side effects. Preprocessing functions should be pure for reproducibility.
# Pure: no side effects, same input -> same output
def normalize(X):
return (X - X.mean()) / X.std()
# Impure: mutates input!
def normalize_bad(X):
X -= X.mean() # Modifies original data!
X /= X.std()
return X
Pure functions are easier to test, debug, and parallelize. In ML pipelines, pure preprocessing functions ensure reproducibility across runs.
A pure function maps input to output deterministically with no side effects: always. Referential transparency means any call can be replaced by its result without changing the program's behavior. Pure functions enable memoization — caching results in a lookup table turns repeated calls from to at the cost of memory for unique inputs. In ML, pure preprocessing functions guarantee reproducibility: given the same data and random seed, the output is identical. Impure functions that mutate inputs (like in-place operations X -= mean) create hidden dependencies that break parallelism and caching.
Is this pure? def normalize(X): return (X - X.mean()) / X.std()
Theory Exercise
Problem:
Explain what *args and **kwargs do, and write a function that wraps another function to measure its execution time.
Hints:
- *args captures positional arguments as a tuple
- **kwargs captures keyword arguments as a dict
- Use time.time() to measure execution time
Coding Exercise
Problem:
Write a pure function standardize(values) that returns a new list of z-scores (mean 0, std 1) without mutating its input, with a default-argument fallback for zero variance. Then write a small variadic summarize(*xs, **opts) helper and a lambda to sort samples, demonstrating defaults, *args/**kwargs, and pure-function behavior.
Hints:
- Compute the mean and population std from the input, then return `[(x - mean) / std for x in values]` — build a new list, don't modify `values`.
- Guard against `std == 0` using a default parameter like `eps=1e-9` so you never divide by zero.
- `*xs` collects positional args into a tuple; `**opts` collects keyword args into a dict; use `opts.get('reverse', False)` for a default.
Related Problems on PixelBank
A model has hyperparameters, learned weights, and operations such as fit, predict, and save. Keeping the weights in one dict, the settings in another, and the operations as loose functions that must be handed both works for one model. With five models in a comparison it becomes a tangle of parallel variables that are easy to mix up. What you want is a single object that carries its own data together with the functions that act on it.
The previous topic, Functions, showed how to package logic. A class goes one step further and packages logic together with the state it operates on. It is the pattern behind every model in scikit-learn, PyTorch, and OpenCV.
We begin with class definitions and the self parameter. Next we separate instance attributes from class attributes and use special methods such as __repr__ and __lt__ to hook into Python's built-in syntax. Inheritance builds specialized classes from general ones, and we close with the split between __init__ and fit that every scikit-learn estimator follows.
Definition
A class is a user-defined type that bundles data, called attributes, with functions, called methods, that operate on it. Calling the class creates an instance and runs __init__ to set its attributes. Every method receives the instance as its first parameter, conventionally named self. A subclass inherits its parent's attributes and methods and may override or extend them.
In this topic
- 1Class Definition
- 2Instance vs Class Attributes
- 3Special Methods
- 4Inheritance
- 5The __init__ and fit Pattern
Class Definition
A class is a blueprint for creating objects. Just as a blueprint for a house defines what a house has (rooms, doors, windows) and can do (provide shelter), a class defines what data an object holds and what operations it can perform.
class BoundingBox:
def __init__(self, x, y, width, height, label=''):
# __init__ is the "constructor" - runs when you create an object
self.x = x # Store position
self.y = y
self.width = width # Store dimensions
self.height = height
self.label = label
def area(self): # Method: something a BoundingBox can DO
return self.width * self.height
self: The object itself. When you call box.area(), self refers to box.
A Python class creates a new type object via the type metaclass. Instance creation involves two steps: __new__ allocates memory (typically bytes base + bytes per __dict__ slot), and __init__ initializes attributes. Instance attributes are stored in a per-object __dict__ (a regular dict). With __slots__ = ['x', 'y'], Python skips the dict and uses fixed-offset struct members — saving bytes per instance and making attribute access faster. Method resolution follows the C3 linearization (MRO) algorithm, computed once at class creation in where is the inheritance chain length.
Why does def area(self) need self but you call box.area() without passing it?
Instance vs Class Attributes
Instance attributes are like personal belongings -- each object has its own copy:
box1 = BoundingBox(0, 0, 100, 50)
box2 = BoundingBox(10, 20, 80, 60)
box1.x # 0 - box1's position
box2.x # 10 - box2's position (different!)
Class attributes are like shared resources -- all instances see the same value:
class Model:
MAX_EPOCHS = 1000 # Shared constant
def __init__(self, params):
self.params = params # Each Model has its own params
When to use which? Instance for data that varies (parameters, weights). Class for constants or counters shared across all instances.
Instance attributes live in self.__dict__ — one dict per object. Class attributes live in ClassName.__dict__ — shared by all instances. Attribute lookup follows the descriptor protocol: Python first checks the class hierarchy (via MRO) for data descriptors, then the instance __dict__, then the class hierarchy for non-data descriptors. This lookup is where is the MRO depth, but CPython caches method lookups via version tags on the type object, making repeated access effectively . Accidentally shadowing a class attribute with self.x = ... creates a new instance attribute that hides the class one.
Track total models created: class Model: count = 0. In init: count += 1. Doesn't work! Why?
Special Methods
Dunder (double-underscore) methods let your objects work with Python's built-in operations. They're how you teach Python to add your objects, print them nicely, or use them in len().
class Vector:
def __init__(self, x, y):
self.x, self.y = x, y
def __add__(self, other): # Called when you write v1 + v2
return Vector(self.x + other.x, self.y + other.y)
def __repr__(self): # Called when you print(v)
return f"Vector({self.x}, {self.y})"
def __len__(self): # Called when you write len(v)
return 2
Common dunders: __str__ (user-friendly string), __eq__ (equality), __lt__ (less than, for sorting).
Dunder methods hook into CPython's internal slots — __add__ maps to tp_as_number->nb_add, __len__ maps to tp_as_sequence->sq_length. These are resolved at the type level, not the instance level, so type(obj).__add__ is called rather than obj.__add__. This is why you cannot override dunders on a per-instance basis. __repr__ should produce an unambiguous string (ideally valid Python), while __str__ is for human-readable output. __hash__ must satisfy: if then . Objects that define __eq__ without __hash__ become unhashable (cannot be dict keys).
Make BoundingBox sortable by area and printable nicely. Which dunders?
Inheritance
Inheritance lets you create specialized versions of existing classes. The child class gets everything from the parent, plus can add or override behavior.
class Shape: # Base class - defines interface
def area(self):
raise NotImplementedError # "Subclasses must implement this"
class Rectangle(Shape): # Inherits from Shape
def __init__(self, w, h):
self.w, self.h = w, h
def area(self): # Provides actual implementation
return self.w * self.h
class Square(Rectangle): # Inherits from Rectangle
def __init__(self, side):
super().__init__(side, side) # Call parent's __init__
Real-world example: PyTorch's neural network layers all inherit from nn.Module, and sklearn estimators inherit from BaseEstimator, giving them a shared interface while allowing custom behavior.
Python uses multiple inheritance with the C3 linearization algorithm (MRO) to resolve method lookup order. For a class hierarchy with classes, the MRO is computed in worst-case at class creation time. super() follows the MRO, not just the immediate parent — in diamond inheritance D(B, C) where B(A) and C(A), super() in B calls C's method, not A's. isinstance(obj, cls) checks the MRO chain in where is the depth. Abstract base classes (abc.ABC) enforce interface contracts: @abstractmethod prevents instantiation if the method is not overridden, catching errors at construction time rather than at call time.
PyTorch custom dataset: inherit from Dataset, must implement __len__ and __getitem__. What's the pattern?
The __init__ and fit Pattern
→ →
Most ML libraries follow this pattern: initialize with hyperparameters, fit to data, then predict. This separation of concerns is a fundamental design pattern in machine learning.
class LinearModel:
def __init__(self, learning_rate=0.01):
self.lr = learning_rate # Hyperparameter
self.weights = None # Learned parameter (set during fit)
def fit(self, X, y):
self.weights = learn_weights(X, y, self.lr)
return self
def predict(self, X):
return X @ self.weights
This pattern enables: creating a model once and fitting on different datasets, cloning unfitted models for cross-validation, and inspecting hyperparameters before fitting.
The init/fit/predict pattern separates hyperparameters (set at construction, ) from learned parameters (computed during fit(), typically for samples and features). This separation enables: (1) cloning unfitted estimators with sklearn.base.clone() which copies __init__ args but not fitted state, (2) grid search over hyperparameters by creating many clones, and (3) serialization of trained models via pickle or joblib. The fit() method returns self to enable method chaining: model.fit(X, y).predict(X_new). Fitted attributes use a trailing underscore convention (coef_, intercept_) to distinguish them from hyperparameters.
Why separate init and fit? Why not learn params in init?
Theory Exercise
Problem:
Design a class DataProcessor that stores data and provides methods for basic operations. What attributes and methods would it need?
Hints:
- Think about what data it holds (samples, features, labels)
- Consider common operations (normalize, split, describe)
- How would you make it printable?
Coding Exercise
Problem:
Implement a pure-Python MinMaxScaler class that follows the scikit-learn fit/transform pattern. fit(data) learns the min and max as instance attributes; transform(data) scales values into [0, 1]; include a class attribute for the feature range, a __repr__, and a subclass that overrides the range to show inheritance.
Hints:
- Store learned state in `__init__`/`fit` as instance attributes (`self.min_`, `self.max_`); keep `feature_range` as a class attribute shared by all instances.
- `fit` should return `self` so calls can chain; `transform` maps each x via `(x - min_) / (max_ - min_)`.
- Define `__repr__` for a readable print, and create a subclass that sets a different `feature_range` class attribute and reuses the parent logic via inheritance.
Related Problems on PixelBank
A dataset of one million samples with 100 features holds 100 million numbers. A plain Python loop that touches each one runs the interpreter 100 million times, which takes minutes for work the CPU could finish in well under a second. Python lists also store every number as a separate object with its own type header, so the same data takes several times more memory than it needs. Machine learning at any real scale needs contiguous numeric arrays and operations that run in compiled code.
The previous topic, Classes & Objects, showed how libraries package data with behaviour. NumPy's ndarray and Pandas' DataFrame are exactly such classes, and they are the containers every later chapter assumes.
We cover the two ideas that make array code fast and short. Vectorization replaces element-by-element Python loops with whole-array operations that run in C. Broadcasting then lets arrays of different shapes combine without copying, which turns per-feature centering and scaling into single expressions. Pandas builds labelled, tabular DataFrames on the same machinery.
Definition
NumPy's ndarray is a fixed-type, n-dimensional array stored in one contiguous block of memory and described by a shape and a dtype. Vectorized operations run compiled loops over whole arrays at once. Broadcasting is the rule that aligns two shapes from the right and stretches any dimension of size 1 to match the other. A Pandas DataFrame is a table of labelled columns, each backed by such an array.
In this topic
- 1Vectorization
- 2Broadcasting
Vectorization
Instead of looping through elements, NumPy performs operations on entire arrays at once. This is 10-100x faster than Python loops.
import numpy as np
# Slow: Python loop
result = []
for x in data:
result.append(x * 2 + 1)
# Fast: Vectorized
result = data * 2 + 1 # Operates on entire array at once
NumPy uses optimized C code and SIMD (Single Instruction, Multiple Data) instructions under the hood, making vectorized operations dramatically faster.
NumPy vectorization replaces Python's interpreter loop with compiled C loops over contiguous memory. A Python for loop processes one element per ns (bytecode dispatch, type checking, boxing). A NumPy vectorized operation processes one element per –5ns using SIMD instructions (SSE/AVX) that operate on 4–8 floats simultaneously. For elements, the speedup is roughly –. Matrix multiplication with costs FLOPs — NumPy delegates to BLAS libraries (OpenBLAS, MKL) that achieve near-peak hardware throughput via cache-blocking and vectorized kernels.
Compute y = X @ w + b where X is (1000, 100), w is (100,), b is scalar. Loop vs vectorized approach?
Broadcasting
NumPy automatically expands arrays of different shapes to perform element-wise operations. For example, adding a scalar to an array adds it to every element.
X = np.random.randn(100, 3) # 100 samples, 3 features
mean = X.mean(axis=0) # Shape: (3,)
centered = X - mean # Broadcasting: (100,3) - (3,) -> (100,3)
Broadcasting rules: dimensions are compared from right to left. Dimensions must be equal or one of them must be 1.
Broadcasting eliminates explicit data replication by virtually expanding arrays during element-wise operations. Rules: align shapes from the right; dimensions of size stretch to match the other array. with and subtracts each column's mean without copying times — the actual memory used is for the output, not . Broadcasting is valid when, for each dimension, sizes are either equal or one of them is . Incompatible shapes raise ValueError. Outer products are a broadcasting pattern: a[:, None] * b[None, :] computes for all .
X has shape (100, 3). mean has shape (3,). What's the shape of X - mean? What does this compute?
Coding Exercise
Problem:
Create a NumPy array of shape (10, 3) with random values, then calculate the mean of each column.
Hints:
- Use np.random.randn() for random values
- Use axis parameter in np.mean()
Related Problems on PixelBank
A CSV of customer records arrives. It has 50,000 rows, but the age column is stored as text because one row says "unknown", 3% of incomes are missing, and loading the whole thing as 64-bit floats needs more memory than your laptop has. A model trained without noticing any of this will still run and report a score, and the score will be wrong. Loading data is not one line of code. It is the first check that the data are what you think they are.
The previous topic, NumPy/Pandas Basics, introduced arrays and DataFrames. Here you fill them from real sources: CSV and Excel files for tabular data, JSON for nested API responses, images for vision, and SQL queries for databases.
We cover two practices. The first is a fixed inspection routine run right after loading, using info, describe, head, and a missing-value count, to catch wrong types, outliers, and gaps before modelling. The second is memory efficiency: smaller dtypes, categorical columns, and chunked reading, so that large files fit in RAM.
Definition
Data loading reads raw data from files, databases, or APIs into in-memory structures such as a Pandas DataFrame with explicit column names and dtypes. It is followed by validation, which checks shapes, types, missing values, and value ranges before any modelling. The memory an array needs is its number of rows times its number of columns times the bytes per value set by the dtype.
In this topic
- 1Data Loading Best Practices
- 2Memory Efficiency
Data Loading Best Practices
A file that loads without errors can still be wrong: a numeric column parsed as text, a sentinel such as −999 standing in for missing values, or a target dominated by one class. Pandas offers a fixed first-look routine for the DataFrame from NumPy/Pandas Basics. df.info() lists each column's dtype and non-null count, df.describe() reports count, mean, standard deviation, and quartiles for numeric columns, df.head() shows raw rows, and df.isnull().sum() counts missing values per column. An object dtype on a column you expected to be numeric is the classic sign of a stray string. Run the routine before modelling, and check class balance with value_counts().
df = pd.read_csv('data.csv')
df.info(); print(df.describe()); print(df.isnull().sum())
Exploratory data analysis (EDA) before modeling prevents garbage-in, garbage-out. df.info() runs in to compute non-null counts per column. df.describe() computes statistics (mean, std, quartiles) for each numeric column using a single pass with Welford's online algorithm for numerically stable variance. Missing value patterns matter: MCAR (missing completely at random) can be safely dropped, MAR (missing at random) can be imputed, and MNAR (missing not at random) requires domain knowledge. Class imbalance detection via value_counts() is and critical — a 99:1 imbalance means a dummy classifier achieves accuracy.
You load a CSV. First thing to do?
Memory Efficiency
Memory for a numeric array is rows × columns × bytes per value, so the dtype sets the bill. float64 uses 8 bytes per value, float32 uses 4, and float16 uses 2, and neural networks almost always train in float32 or lower. Integer columns can shrink to int8 when their values fit between −128 and 127. A string column with few distinct values, such as a country code, shrinks as a category, which stores a small integer code per row plus one table of unique strings. For files that will not fit, pd.read_csv(..., chunksize=n) yields DataFrames piece by piece. Narrower types cost precision and risk overflow.
X = X.astype(np.float32) # half the bytes of float64
df['city'] = df['city'].astype('category')
A float64 array of elements uses exactly bytes of contiguous memory plus a small header ( bytes). Downcasting to float32 halves memory to bytes with significant digits of precision — sufficient for most ML since gradient noise exceeds float32 precision loss. Pandas Categorical dtype stores unique values once plus an integer index per row: memory is vs for strings of average length . Chunked loading with pd.read_csv(chunksize=m) processes rows at a time using memory, enabling processing of files larger than RAM in a streaming fashion.
10M samples x 100 features. float64 uses 8GB. How to reduce to 4GB?
Coding Exercise
Problem:
Load the Iris dataset from sklearn, perform a 80-20 train-test split, and print the shapes of all resulting arrays.
Hints:
- Use load_iris() from sklearn.datasets
- Use train_test_split from sklearn.model_selection
- Set random_state for reproducibility
Related Problems on PixelBank
You trained a logistic regression, and now you want to know whether a random forest does better. In many libraries, switching models means learning new function names, a new data format, and new conventions for probabilities. Preprocessing can also leak: compute the scaling statistics on all the data before splitting, and your test score quietly includes information from the test set. Both problems call for one library in which every model behaves the same way and preprocessing is fitted only where it should be.
The previous topic, Data Loading, produced clean arrays and DataFrames, and scikit-learn consumes exactly those. Its design follows the __init__ and fit pattern from Classes & Objects, with hyperparameters set in the constructor and learned state stored after fitting.
The workflow has five steps: import the model class, instantiate it with hyperparameters, fit it to training data, predict on new data, and evaluate the predictions. We study the estimator API that makes models interchangeable, the transformers that preprocess data, and the pipelines that chain both so that leakage cannot happen.
Definition
Scikit-learn is a Python machine learning library, built on NumPy and SciPy, with one uniform API. An estimator learns from data through fit(X, y), where X has shape (n_samples, n_features). A predictor adds predict(X), and a transformer adds transform(X). A Pipeline chains transformers and a final estimator into a single estimator that is fitted and applied as one.
In this topic
- 1Estimator API
- 2Transformers
- 3Pipelines
Estimator API
→
Before scikit-learn, every algorithm library had its own calling conventions, so comparing five models meant writing five sets of glue code. Scikit-learn fixes one interface, shown in the formula. estimator is any model object, X is the feature matrix of shape (n_samples, n_features), and y holds the n_samples targets. fit learns parameters and returns the estimator itself, and predict maps new rows to outputs. Classifiers add predict_proba, and score, which defaults to accuracy. This is the __init__ and fit pattern from Classes & Objects applied to every model, so swapping LogisticRegression() for RandomForestClassifier() changes one line. Calling predict before fit raises NotFittedError.
The Estimator API enforces a consistent interface: fit(X, y) learns model parameters from training data, predict(X) applies the learned mapping. For linear models, fit() solves in via the normal equations or via iterations of gradient descent. For tree-based models, fit() is per tree. The score() method computes accuracy (classification) or (regression) in . get_params()/set_params() enable programmatic hyperparameter access — the foundation of GridSearchCV which evaluates parameter combinations.
LogisticRegression, RandomForestClassifier, SVC. How do they all work the same way?
Transformers
→
Many models behave badly when features sit on very different scales, such as age in tens beside income in tens of thousands. A transformer learns a preprocessing rule from data and then applies it. In the formula, fit(X) estimates the rule's parameters, which for StandardScaler are each column's mean and standard deviation , and transform(X) applies column by column, using the broadcasting from NumPy/Pandas Basics. The rule must be fitted on training data only and then applied unchanged to validation and test data. Fitting it on the full dataset leaks test statistics into training and inflates scores. fit_transform combines both steps for the training set.
Transformers follow the fit/transform pattern: fit(X) learns statistics in (e.g., StandardScaler computes and per feature), and transform(X) applies in . fit_transform(X) combines both in a single pass when possible. The critical rule — fit on train only — prevents data leakage: if test statistics inform the transformation, the model indirectly "sees" test data, inflating evaluation metrics. MinMaxScaler maps to via .
StandardScaler: what does fit() learn? What does transform() do?
Pipelines
Calling a scaler and a model by hand works until someone forgets to transform the test set, or fits the scaler on all the data inside cross-validation, which leaks each validation fold's statistics into training. A Pipeline chains named steps: every step but the last is a transformer, and the last is any estimator. pipe.fit(X, y) calls fit_transform on each transformer in turn and fits the final model on the result. pipe.predict(X) calls only transform on the transformers, reusing what they learned. The whole pipeline is itself an estimator, so it can be cross-validated, grid-searched, and saved as one object. Step parameters are addressed as step__param, such as model__C.
A Pipeline chains transformers and a final estimator. pipe.fit(X, y) calls fit_transform() on each transformer sequentially, passing the output as input to the next stage — total complexity is where is the cost of stage . During cross-validation, the pipeline refits all transformers on each fold's training set, preventing information leakage across folds. pipe.predict(X) calls transform() (not fit_transform()) on each transformer, then predict() on the final estimator. Pipeline exposes nested parameters via __ notation: pipe.set_params(model__C=0.1) sets the regularization parameter of the model step.
Without pipeline: scaler.fit(X_train); X_train = scaler.transform(X_train); model.fit(X_train, y). What's the risk?
Theory Exercise
Problem:
Why is it important to fit the scaler only on training data and then transform both training and test data with the same fitted scaler?
Hints:
- Think about data leakage
- What information would the scaler learn from test data?
- Consider the deployment scenario
Coding Exercise
Problem:
Create a sklearn Pipeline that scales features with StandardScaler and then applies RandomForestClassifier. Train it on the iris dataset.
Hints:
- Import Pipeline from sklearn.pipeline
- Import RandomForestClassifier from sklearn.ensemble
- Pipeline takes a list of (name, estimator) tuples