Essential Python programming for AI and ML: variables, data structures, control flow, functions, classes, data manipulation with NumPy/Pandas, and introduction to scikit-learn.
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:
What you'll learn in this chapter:
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.
Click any topic to jump in
Python's type system, mutability, and memory model — the building blocks everything else depends on.
Sequences and mappings
Ordered sequences, slicing, and comprehensions — the core data structures for processing datasets.
Hash-based key-value storage — O(1) lookups that power feature maps, configs, and data pipelines.
Iteration and abstraction
Conditionals, loops, iteration patterns — controlling program execution for data processing and training loops.
Defining reusable logic with args, kwargs, lambdas — composing operations for clean ML pipelines.
Object-oriented patterns, inheritance, and special methods — the foundation of PyTorch modules and sklearn estimators.
NumPy, Pandas, and scikit-learn
Vectorized computation and DataFrames — replacing Python loops with fast array operations.
Reading datasets efficiently with generators and memory mapping — handling data larger than RAM.
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.
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
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 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 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:
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 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.
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)
What will be printed? Explain each step.
a = 10
b = a
a = 20
print(b)
Create a dictionary that maps model names to their hyperparameter dictionaries.