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.
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.
Every program starts with data. In Python, variables are names that refer to objects in memory. Understanding how Python handles different data types -- integers, floats, strings, booleans -- is fundamental to writing correct code.
Python's dynamic typing means variables don't have fixed types: the same variable can hold an integer, then a string. This flexibility is powerful but requires understanding how types work. Modern Python also supports type annotations for documentation and tooling, and understanding mutability is critical for avoiding subtle bugs when working with data.
In this topic
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
Type hints help document expected data shapes and catch errors early. Modern Python code often uses type annotations for clarity, especially in ML and data science projects.
def predict(X: np.ndarray) -> np.ndarray:
return self.model.predict(X)
def load_data(path: str) -> tuple[np.ndarray, np.ndarray]:
...
Type hints are not enforced at runtime -- they serve as documentation and enable IDE tooling to catch mistakes before you run the code.
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