Iterator & Generator Protocol¶
Overview¶
Iterators and generators are fundamental to ML pipelines:
- DataLoaders use iterators to stream data
- Generators are memory-efficient (don't load entire dataset)
- Yield enables lazy evaluation for large datasets
- JAX transformations leverage generator composition
This is why PyTorch DataLoaders don't load all data into memory upfront.
The Iterator Protocol¶
Core Concept¶
An iterator is an object that implements two methods:
class Iterator:
def __iter__(self):
"""Return self (iterator object)."""
return self
def __next__(self):
"""Return next value, raise StopIteration when done."""
if self.has_next():
return self.get_next()
raise StopIteration
Simple Example¶
# Built-in iterator
data = [1, 2, 3]
iterator = iter(data) # Calls data.__iter__()
print(next(iterator)) # 1, calls iterator.__next__()
print(next(iterator)) # 2
print(next(iterator)) # 3
print(next(iterator)) # StopIteration exception
# For loop uses iterator protocol
for value in data: # Implicitly calls iter() and __next__()
print(value)
Custom Iterator¶
class RangeIterator:
"""Custom iterator (like range)."""
def __init__(self, start, end):
self.current = start
self.end = end
def __iter__(self):
return self
def __next__(self):
if self.current < self.end:
value = self.current
self.current += 1
return value
raise StopIteration
# Usage
for value in RangeIterator(0, 5):
print(value) # 0, 1, 2, 3, 4
Generators: Simplified Iterators¶
Core Concept¶
A generator is a function that yields values instead of returning:
# Generator function (uses yield)
def count_to_n(n):
"""Generator that yields 0 to n-1."""
i = 0
while i < n:
yield i # Pause here, return value, resume when next() called
i += 1
# Generator object (not executed yet)
gen = count_to_n(3)
print(type(gen)) # <class 'generator'>
# Iteration
print(next(gen)) # 0 (function resumes after first yield)
print(next(gen)) # 1
print(next(gen)) # 2
print(next(gen)) # StopIteration
Why Generators Matter for ML¶
# WITHOUT generators
def load_all_data():
data = []
for line in open("huge_dataset.txt"):
data.append(process(line)) # Stores everything in RAM
return data
all_data = load_all_data() # Uses gigabytes of RAM!
# WITH generators
def load_data_lazy():
for line in open("huge_dataset.txt"):
yield process(line) # Only one sample in RAM at a time
for sample in load_data_lazy():
# Process one sample at a time
train_model(sample)
Memory Comparison:
All-at-once: Load 1B samples × 1KB each = 1TB RAM (impossible!)
Generator: Load 1 sample at a time = 1KB RAM (feasible!)
Generator Expressions¶
Lazy versions of list comprehensions:
# List comprehension
squares_list = [x**2 for x in range(1000000)] # Allocates huge list
# Generator expression
squares_gen = (x**2 for x in range(1000000)) # Tiny object
# Iterate through generator
for square in squares_gen:
print(square)
# Or convert to list if needed (but then defeats purpose)
squares_list = list(squares_gen) # Now it's big
Chaining Generators¶
# Multiple generators compose efficiently
data = range(1000000)
# Don't do this (allocates intermediate lists)
filtered = [x for x in data if x % 2 == 0]
squared = [x**2 for x in filtered]
top_100 = squared[:100]
# Do this (composable, lazy)
filtered = (x for x in data if x % 2 == 0)
squared = (x**2 for x in filtered)
top_100 = list(itertools.islice(squared, 100))
PyTorch DataLoader: Iterator Pattern¶
How DataLoader Uses Iterators¶
import torch
from torch.utils.data import DataLoader, Dataset
class MyDataset(Dataset):
"""Custom dataset."""
def __len__(self):
return 1000
def __getitem__(self, idx):
# Called by DataLoader to fetch individual samples
return torch.randn(3, 224, 224), idx % 10
# Create iterator
dataset = MyDataset()
loader = DataLoader(
dataset,
batch_size=32,
shuffle=True,
num_workers=4 # Load batches in parallel
)
# DataLoader is iterable
for batch_images, batch_labels in loader: # Uses iterator protocol
print(batch_images.shape) # (32, 3, 224, 224)
# Process batch
Multiple Epochs¶
model = torch.nn.Linear(10, 5)
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
for epoch in range(3):
# Each epoch creates new iterator over same dataset
for batch_x, batch_y in loader:
# Forward pass
pred = model(batch_x)
loss = torch.nn.functional.mse_loss(pred, batch_y)
# Backward pass
optimizer.zero_grad()
loss.backward()
optimizer.step()
print(f"Epoch {epoch} done")
Key insight: DataLoader creates a fresh iterator each epoch, respecting shuffle order.
Advanced: Stateful Generators¶
Generators can maintain state:
# Generator with state
def batch_generator(data, batch_size):
"""Yield batches of data."""
for i in range(0, len(data), batch_size):
batch = data[i:i+batch_size]
yield batch
# State (i) is preserved between yields
# Usage
batches = batch_generator([1, 2, 3, 4, 5], batch_size=2)
print(next(batches)) # [1, 2]
print(next(batches)) # [3, 4]
print(next(batches)) # [5]
print(next(batches)) # StopIteration
Generator with Initialization¶
def infinite_loader(data):
"""Cycle through data infinitely (for validation)."""
while True:
for sample in data:
yield sample
# Usage in validation loop
gen = infinite_loader(val_dataset)
for _ in range(num_validation_batches):
batch = next(gen)
validate(model, batch)
Important Generator Features¶
send() Method: Bidirectional Communication¶
def echo_generator():
"""Receive values via send()."""
while True:
value = yield # Wait for value to be sent
print(f"Received: {value}")
gen = echo_generator()
next(gen) # Prime the generator (move to first yield)
gen.send("Hello") # Send value into generator
gen.send("World")
# Output:
# Received
# Received
ML Application: Feedback-driven data generation
def curriculum_generator(all_data):
"""Adjust difficulty based on model performance."""
easy_data = [d for d in all_data if d['difficulty'] < 5]
hard_data = [d for d in all_data if d['difficulty'] >= 5]
current = easy_data
while True:
# Yield a batch
batch = yield current
# Receive feedback on model performance
if batch.get('avg_loss') < 0.1:
# Model is doing well, increase difficulty
current = hard_data
else:
# Keep easy examples
current = easy_data
Nested Generators with yield from¶
# yield from (Python 3.3+)
def flatten(nested_list):
"""Flatten nested lists using yield from."""
for item in nested_list:
if isinstance(item, list):
yield from flatten(item) # Delegate to nested generator
else:
yield item
# Usage
data = [1, [2, [3, 4]], 5]
for value in flatten(data):
print(value) # 1, 2, 3, 4, 5
# Practical example
def iter_dataset(root_dir):
"""Recursively iterate all samples."""
for item in os.listdir(root_dir):
path = os.path.join(root_dir, item)
if os.path.isdir(path):
yield from iter_dataset(path) # Recurse
else:
yield path # Leaf node
Itertools: Functional Iterator Tools¶
Common Patterns¶
import itertools
# chain
data1 = [1, 2, 3]
data2 = [4, 5, 6]
combined = itertools.chain(data1, data2)
# islice
first_100 = itertools.islice(all_data, 100)
# cycle
repeated = itertools.cycle([1, 2, 3])
# zip_longest
a = [1, 2]
b = [3, 4, 5]
zipped = itertools.zip_longest(a, b, fillvalue=0) # [(1,3), (2,4), (0,5)]
# groupby
sorted_data = [1, 1, 1, 2, 2, 3, 3, 3, 3]
for key, group in itertools.groupby(sorted_data):
print(f"{key}: {list(group)}")
Practical DataLoader Example¶
from itertools import chain, islice
# Combine train and validation iterators
train_loader = DataLoader(train_dataset, batch_size=32)
val_loader = DataLoader(val_dataset, batch_size=32)
# First train 100 batches, then validate
combined = chain(islice(train_loader, 100), val_loader)
for batch in combined:
if batch in train_loader:
train_step(batch)
else:
val_step(batch)
Performance: Lists vs Generators¶
import sys
# List
list_comp = [x**2 for x in range(1000000)]
print(sys.getsizeof(list_comp)) # ~8MB
# Generator
gen_expr = (x**2 for x in range(1000000))
print(sys.getsizeof(gen_expr)) # ~136 bytes
# Time to create
import time
start = time.time()
list_comp = [x**2 for x in range(10000000)]
print(f"List: {time.time() - start:.3f}s") # ~0.5s
start = time.time()
gen_expr = (x**2 for x in range(10000000))
print(f"Generator: {time.time() - start:.3f}s") # ~0.0s (just creates object)
For ML: Use generators for datasets, lists only when needed.
The Iterable vs Iterator Distinction¶
# Iterable
data = [1, 2, 3]
iterator1 = iter(data)
iterator2 = iter(data)
# iterator1 and iterator2 are different objects!
# Each starts from beginning
# Iterator
iterator = iter([1, 2, 3])
print(iter(iterator) is iterator) # True! Iterator's __iter__ returns self
print(next(iterator)) # 1
print(next(iterator)) # 2
Common Pitfalls¶
Pitfall 1: Exhausted Generators¶
gen = (x**2 for x in range(5))
# First iteration
for x in gen:
print(x)
# Second iteration doesn't work!
for x in gen:
print(x) # Prints nothing - generator is exhausted
# Solution
gen = (x**2 for x in range(5))
Pitfall 2: Closing Generator¶
def my_gen():
try:
yield 1
yield 2
finally:
print("Cleaning up...")
gen = my_gen()
next(gen) # 1
gen.close() # Raises GeneratorExit, triggers finally block
# Output
# Practical
def file_line_generator(filename):
with open(filename) as f:
try:
for line in f:
yield line.strip()
finally:
print("File closed") # Always runs
Summary¶
- Iterators = Objects with
__iter__()and__next__() - Generators = Functions with
yield(simplified iterators) - Generator expressions = Lazy list comprehensions
- DataLoader = Iterator pattern for batching
- Memory efficiency = Process one sample at a time
- Composition = Chain multiple generators efficiently
-
Related Topics¶
- 02 Data Structures Essentials - Lists and batching
- [04 Context Managers & Resource Management](/05-py3/01-fundamentals/(04-context-managers-resource-management/) - Resource management with generators
- 00 Readme - Functional composition
- [04 Profiling & Performance Analysis](/05-py3/09-bytecode-and-execution/(04-profiling-performance-analysis/) - Performance analysis