Essential Python programming concepts needed for deep learning including OOP, data structures, and scientific computing libraries.
Python is the undisputed language of deep learning. Not because it is fast — it is not — but because it serves as an elegant "steering wheel" for highly optimized C++ and CUDA backends. When you write model(x) in PyTorch, the Python layer dispatches to optimized GPU kernels. Your job is to orchestrate these operations clearly and correctly. This guide covers the Python skills you must have before writing your first neural network.
Why Python Dominates Deep Learning
- Rich ecosystem: NumPy, PyTorch, TensorFlow, JAX, Hugging Face, scikit-learn — the entire modern ML stack is Python-first
- Clean syntax: Readable code matters when your training script is 500 lines and a single bug means wasting 8 hours of GPU time
- Interactive development: Jupyter notebooks let you experiment, visualize, and iterate rapidly
- GPU bindings: Python wraps CUDA kernels seamlessly — you write Python, the GPU does the work
- Community: Every paper, tutorial, and Stack Overflow answer is in Python
Classes and Object-Oriented Programming
Every PyTorch model is a Python class that inherits from torch.nn.Module. If you do not understand classes, inheritance, and the __init__/__call__ pattern, PyTorch code will look like magic. Let us demystify it.
# A basic Python class — the pattern every PyTorch model follows
class NeuralNetwork:
"""A simple two-layer neural network implemented from scratch."""
def __init__(self, input_size, hidden_size, output_size):
"""Constructor: called once when you create the object.
In PyTorch, this is where you define layers (nn.Linear, nn.Conv2d, etc.)
"""
import numpy as np
# Initialize weight matrices with small random values
self.W1 = np.random.randn(input_size, hidden_size) * 0.01
self.b1 = np.zeros(hidden_size)
self.W2 = np.random.randn(hidden_size, output_size) * 0.01
self.b2 = np.zeros(output_size)
self.learning_rate = 0.01
def forward(self, x):
"""Forward pass: compute predictions from inputs.
In PyTorch, this method defines the computation graph.
"""
import numpy as np
# Layer 1: linear transformation + ReLU activation
self.z1 = x @ self.W1 + self.b1
self.a1 = np.maximum(0, self.z1) # ReLU
# Layer 2: linear transformation (output layer)
self.z2 = self.a1 @ self.W2 + self.b2
return self.z2
def __repr__(self):
"""String representation — useful for debugging."""
return f"NeuralNetwork({self.W1.shape[0]}→{self.W1.shape[1]}→{self.W2.shape[1]})"
def __call__(self, x):
"""Makes the object callable: model(x) calls model.forward(x).
This is exactly what PyTorch's nn.Module does.
"""
return self.forward(x)
# Usage — this is how you'll use PyTorch models too
import numpy as np
model = NeuralNetwork(784, 128, 10)
print(model) # NeuralNetwork(784→128→10)
# Making predictions
x = np.random.randn(32, 784) # batch of 32 images
output = model(x) # calls __call__ → forward
print(f"Output shape: {output.shape}") # (32, 10) — 10 class scores
Inheritance — The PyTorch Pattern
# In PyTorch, every model inherits from nn.Module:
import torch
import torch.nn as nn
class MyModel(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
super().__init__() # MUST call parent's __init__
self.layer1 = nn.Linear(input_dim, hidden_dim)
self.relu = nn.ReLU()
self.layer2 = nn.Linear(hidden_dim, output_dim)
def forward(self, x):
x = self.layer1(x)
x = self.relu(x)
x = self.layer2(x)
return x
# nn.Module provides: parameter tracking, .to(device), .train()/.eval(),
# state_dict saving, gradient computation — all for free via inheritance
model = MyModel(784, 256, 10)
print(f"Total parameters: {sum(p.numel() for p in model.parameters())}")
List Comprehensions and Generator Expressions
These are Python's most Pythonic features and appear constantly in data processing pipelines.
# List comprehension — concise way to create lists
# Pattern: [expression for item in iterable if condition]
# Filter and transform data
raw_scores = [0.1, 0.9, 0.3, 0.8, 0.05, 0.95]
confident_predictions = [s for s in raw_scores if s > 0.5]
print(confident_predictions) # [0.9, 0.8, 0.95]
# Create batch of file paths
image_paths = [f"data/train/image_{i:04d}.png" for i in range(1000)]
# Nested comprehension — flattening
batches = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
all_items = [item for batch in batches for item in batch]
print(all_items) # [1, 2, 3, 4, 5, 6, 7, 8, 9]
# Dictionary comprehension — mapping layer names to sizes
layer_sizes = {f"layer_{i}": 2**i for i in range(5, 10)}
print(layer_sizes) # {'layer_5': 32, 'layer_6': 64, ..., 'layer_9': 512}
Generators — Memory-Efficient Data Loading
When your dataset has millions of samples, you cannot load everything into RAM. Generators produce items one at a time, on demand:
def data_generator(file_paths, batch_size=32):
"""Yields batches lazily — only one batch in memory at a time.
This pattern is the foundation of PyTorch's DataLoader.
"""
import numpy as np
batch = []
for path in file_paths:
sample = load_and_preprocess(path) # your loading function
batch.append(sample)
if len(batch) == batch_size:
yield np.array(batch) # yield pauses here until next() is called
batch = [] # release memory
if batch:
yield np.array(batch) # yield remaining samples
# Usage — processes millions of files with constant memory
for batch in data_generator(image_paths, batch_size=64):
predictions = model(batch)
# ... process predictions ...
# Key insight: a generator remembers where it left off between yields.
# Unlike a list, it doesn't compute or store all values upfront.
Decorators — Extending Function Behavior
Decorators wrap functions to add functionality without modifying their code. You will encounter them constantly in deep learning codebases.
import time
import functools
# A timing decorator — measure how long functions take
def timer(func):
@functools.wraps(func) # preserves original function's name and docstring
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start
print(f"[{func.__name__}] completed in {elapsed:.2f}s")
return result
return wrapper
@timer
def train_epoch(model, dataloader, optimizer):
"""Train for one epoch."""
total_loss = 0
for batch_x, batch_y in dataloader:
loss = compute_loss(model(batch_x), batch_y)
total_loss += loss
return total_loss
# When you call train_epoch(...), it automatically prints timing info
# PyTorch uses decorators extensively:
# @torch.no_grad() — disables gradient computation (faster inference)
# @torch.jit.script — compiles function to TorchScript (faster execution)
# @staticmethod — method that doesn't use self
Context Managers — Resource Management
The with statement ensures resources are properly acquired and released. In deep learning, it is essential for controlling gradient computation and device placement.
import torch
# torch.no_grad() — the most common context manager in DL
# Disables gradient tracking during inference (saves memory and computation)
model.eval() # Set model to evaluation mode (affects dropout, batchnorm)
with torch.no_grad():
# Inside this block, no gradients are computed or stored
predictions = model(test_data)
# This saves ~50% memory since intermediate activations aren't cached
# torch.cuda.amp.autocast() — mixed precision training
# Uses float16 where safe, float32 where needed → 2x speedup
with torch.cuda.amp.autocast():
output = model(input_data)
loss = criterion(output, targets)
# Custom context manager for logging
from contextlib import contextmanager
@contextmanager
def training_phase(name):
"""Log start and end of training phases."""
print(f"[START] {name}")
start = time.time()
yield # code inside 'with' block runs here
elapsed = time.time() - start
print(f"[END] {name} ({elapsed:.1f}s)")
with training_phase("Epoch 1"):
train_epoch(model, train_loader, optimizer)
Type Hints — Self-Documenting Code
Modern Python deep learning code uses type hints extensively. They make code readable and enable IDE autocomplete.
from typing import List, Tuple, Optional, Dict
import numpy as np
import torch
from torch import Tensor
def create_batches(
data: np.ndarray,
labels: np.ndarray,
batch_size: int = 32,
shuffle: bool = True
) -> List[Tuple[np.ndarray, np.ndarray]]:
"""Split data into batches, optionally shuffled.
Args:
data: Input features of shape (N, D)
labels: Target labels of shape (N,)
batch_size: Number of samples per batch
shuffle: Whether to randomize order
Returns:
List of (data_batch, label_batch) tuples
"""
n = len(data)
if shuffle:
indices = np.random.permutation(n)
data, labels = data[indices], labels[indices]
batches = []
for i in range(0, n, batch_size):
batch_data = data[i:i + batch_size]
batch_labels = labels[i:i + batch_size]
batches.append((batch_data, batch_labels))
return batches
def compute_accuracy(
predictions: Tensor,
targets: Tensor
) -> float:
"""Compute classification accuracy."""
predicted_classes = predictions.argmax(dim=1)
correct = (predicted_classes == targets).sum().item()
return correct / len(targets)
from pathlib import Path
from collections import defaultdict
import json
import os
# pathlib — modern file path handling (preferred over os.path)
data_dir = Path("data/imagenet/train")
image_files = list(data_dir.glob("**/*.jpg")) # recursive search
print(f"Found {len(image_files)} images")
# For each class subdirectory, count images
class_counts = defaultdict(int)
for img_path in image_files:
class_name = img_path.parent.name
class_counts[class_name] += 1
# Save/load experiment configs with JSON
config = {
"model": "resnet50",
"batch_size": 64,
"learning_rate": 0.001,
"epochs": 100,
"optimizer": "adam"
}
with open("config.json", "w") as f:
json.dump(config, f, indent=2)
# Environment variables for GPU selection
os.environ["CUDA_VISIBLE_DEVICES"] = "0,1" # Use only GPUs 0 and 1
Lambda Functions and Functional Programming
# Lambda — anonymous one-liner functions
# Used extensively with transforms and sorting
# Sort models by validation accuracy
results = [
{"model": "resnet18", "accuracy": 0.76},
{"model": "resnet50", "accuracy": 0.82},
{"model": "vgg16", "accuracy": 0.79},
]
sorted_results = sorted(results, key=lambda x: x["accuracy"], reverse=True)
# map and filter — functional data processing
file_names = ["train_001.jpg", "train_002.png", "test_001.jpg", "train_003.jpg"]
train_files = list(filter(lambda f: f.startswith("train"), file_names))
jpeg_files = list(filter(lambda f: f.endswith(".jpg"), file_names))
# PyTorch uses lambdas in learning rate schedulers:
# scheduler = LambdaLR(optimizer, lr_lambda=lambda epoch: 0.95 ** epoch)
Key Libraries Ecosystem
| Library | Purpose | You Will Use It For |
|---|
| NumPy | Numerical arrays | Data preprocessing, manual implementations |
| Pandas | Tabular data | Loading CSVs, exploratory analysis |
| Matplotlib/Seaborn | Plotting | Training curves, confusion matrices |
| PyTorch | Deep learning | Model building, training, GPU computation |
| TensorFlow/Keras | Deep learning | Alternative framework, deployment with TF Serving |
| scikit-learn | Classical ML | Metrics, train/test split, baselines |
| Hugging Face | Pre-trained models | NLP tasks, fine-tuning transformers |
| Weights & Biases | Experiment tracking | Logging metrics, hyperparameter sweeps |
Interview Questions
- Why is Python preferred for deep learning despite being slow?
Python serves as a high-level orchestration layer. The actual computation happens in optimized C++/CUDA kernels (via PyTorch, NumPy). Python's readability, rich ecosystem, and rapid prototyping capabilities far outweigh its interpreter overhead, which is negligible compared to GPU computation time.
- Explain how PyTorch uses Python classes for neural networks.
PyTorch models inherit from nn.Module. The __init__ method defines layers and parameters. The forward method defines the computation graph. nn.Module provides automatic parameter tracking, gradient computation, device management, and serialization — all through Python's OOP mechanisms.
- What is the difference between a list and a generator in Python?
Lists store all elements in memory simultaneously. Generators compute and yield values one at a time on demand, using constant memory regardless of dataset size. For deep learning data pipelines processing millions of files, generators are essential to avoid out-of-memory errors.
- Why do we use
with torch.no_grad() during inference?
It disables gradient computation and intermediate activation caching. During inference, we do not need gradients for backpropagation, so this reduces memory usage by approximately 50% and speeds up computation. It is semantically equivalent to calling .detach() on every tensor.
- What does
super().__init__() do in a PyTorch model?
It calls the parent class (nn.Module) constructor, which initializes the internal parameter registry, hook system, and module tracking infrastructure. Without this call, model.parameters() would return nothing and gradient computation would fail silently.