Day 4 of 180 - Collections, Control Flow & Object-Oriented Programming
Part of my 180-day AI Engineering journey - learning in public, one hour a day, writing everything in plain English so beginners can follow along. The blog is written with the help of AI
Introduction
By the end of today’s 1.5-hour session, you’ll understand the four pillars of Python programming:
- Collections: Lists (ordered shelves) and dicts (labeled drawers)
- Control Flow: Loops that repeat code
- Functions: Reusable code recipes
- Classes & OOP: Blueprints for objects
Why these matter for AI Engineering: Every single AI program you write will use all four of these. Lists store your data. Loops process that data. Functions make your code DRY (Don’t Repeat Yourself). Classes organize code so a 100,000-line project doesn’t become spaghetti.
Best of all, you’ll build a working linear regression model from scratch using only pure Python-no NumPy, no PyTorch, no magic. You’ll see how AI training actually works: it’s not magic, just math.
Setup
Day 4 needs nothing but Python. No external libraries yet. Just create a working directory:
mkdir -p ~/ai-engineering/day-4
cd ~/ai-engineering/day-4
python3 --version # Should be 3.8+
All code today runs directly: python3 script.py
Part 1: Lists & Dicts - The Data Containers
Every program needs to store data. Python gives you two main containers: lists (ordered) and dicts (labeled).
Lists: Ordered Shelves
Think of a list like a shelf in a grocery store. Items sit in a specific order: position 0, position 1, position 2, etc. You find things by their position.
# Create a list
fruits = ["apple", "banana", "cherry"]
# Access by position (0-indexed!)
print(fruits[0]) # "apple" (first item, index 0)
print(fruits[1]) # "banana" (second item, index 1)
print(fruits[-1]) # "cherry" (last item, use -1)
# Modify
fruits[1] = "blueberry"
print(fruits) # ["apple", "blueberry", "cherry"]
# Add items
fruits.append("date") # Add one item to end
print(fruits) # ["apple", "blueberry", "cherry", "date"]
fruits.extend(["fig", "grape"]) # Add multiple items
print(fruits) # ["apple", "blueberry", "cherry", "date", "fig", "grape"]
# Remove items
removed = fruits.pop(0) # Remove and return first item
print(removed) # "apple"
print(fruits) # ["blueberry", "cherry", "date", "fig", "grape"]
fruits.remove("cherry") # Remove by value (not index)
print(fruits) # ["blueberry", "date", "fig", "grape"]
# How many?
print(len(fruits)) # 4
# Slice: get a range
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
print(numbers[2:5]) # [2, 3, 4] (index 2 to 5, excludes 5)
print(numbers[:3]) # [0, 1, 2] (from start to index 3)
print(numbers[5:]) # [5, 6, 7, 8, 9] (from index 5 to end)
print(numbers[::2]) # [0, 2, 4, 6, 8] (every other item)
Dicts: Labeled Drawers
A dict is like a filing cabinet with labeled drawers. Each item has a name (the “key”) and a value (what’s inside). You find things by their label, not their position.
# Create a dict
student = {
"name": "Alice",
"age": 25,
"major": "Computer Science",
"gpa": 3.8
}
# Access by key (label)
print(student["name"]) # "Alice"
print(student["gpa"]) # 3.8
# Safe access (won't crash if key doesn't exist)
print(student.get("age")) # 25
print(student.get("email")) # None (key doesn't exist)
print(student.get("email", "N/A")) # "N/A" (provide default)
# Update
student["age"] = 26
student["gpa"] = 3.9
# Add new key
student["phone"] = "555-1234"
# Remove key
del student["major"]
# Check if key exists
if "name" in student:
print(f"Name: {student['name']}")
if "email" not in student:
print("No email on file")
# Get all keys
print(student.keys()) # dict_keys(['name', 'age', 'gpa', 'phone'])
# Get all values
print(student.values()) # dict_values(['Alice', 26, 3.9, '555-1234'])
# Get all key-value pairs
for key, value in student.items():
print(f"{key}: {value}")
# name: Alice
# age: 26
# gpa: 3.9
# phone: 555-1234
Dicts with Defaults: defaultdict
Sometimes you want a dict to have a default value if a key doesn’t exist yet:
from collections import defaultdict
# Regular dict would crash here:
# scores = {}
# scores["Alice"] += 10 # KeyError!
# defaultdict doesn't crash-it uses a default value
scores = defaultdict(int) # Default value is 0
scores["Alice"] += 10 # Works! Creates key with 0, then adds 10
scores["Bob"] += 5
print(scores["Alice"]) # 10
print(scores["Bob"]) # 5
print(scores["Charlie"]) # 0 (never been set, but default is 0)
When to Use List vs Dict
| Situation | Use List | Use Dict |
|---|---|---|
| “Give me the 3rd item” | YES | NO |
| “Find the item called ‘age’” | NO | YES |
| Order matters | YES | NO |
| Fast lookup by name | NO | YES |
| You know the keys ahead of time | NO | YES |
| Keys are things like “name”, “age”, “email” | NO | YES |
Part 2: Loops - Repeating Code
Loops let you repeat code without writing it 100 times. Two types: for (when you know how many times) and while (when you loop until a condition is true).
The for Loop: Repeat Over a Collection
# Loop over a list
fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
print(fruit)
# Output:
# apple
# banana
# cherry
# Loop over a dict
student = {"name": "Alice", "age": 25, "major": "CS"}
for key in student:
value = student[key]
print(f"{key}: {value}")
# Output:
# name: Alice
# age: 25
# major: CS
# Better way with .items()
for key, value in student.items():
print(f"{key}: {value}")
range(): Generate Numbers
# range(n) = 0, 1, 2, ..., n-1
for i in range(5):
print(i)
# Output: 0 1 2 3 4
# range(start, stop, step)
for i in range(2, 8, 2): # Start at 2, stop before 8, step by 2
print(i)
# Output: 2 4 6
# Count backwards
for i in range(5, 0, -1): # Start at 5, count down
print(i)
# Output: 5 4 3 2 1
enumerate(): Loop with Index
Often you want both the index (position) and the value:
fruits = ["apple", "banana", "cherry"]
# Without enumerate (awkward)
for i in range(len(fruits)):
print(f"{i}: {fruits[i]}")
# With enumerate (clean)
for i, fruit in enumerate(fruits):
print(f"{i}: {fruit}")
# Output:
# 0: apple
# 1: banana
# 2: cherry
zip(): Loop Over Multiple Lists Together
names = ["Alice", "Bob", "Charlie"]
ages = [25, 30, 28]
# Loop through both at once
for name, age in zip(names, ages):
print(f"{name} is {age} years old")
# Output:
# Alice is 25 years old
# Bob is 30 years old
# Charlie is 28 years old
Nested Loops: Loops Inside Loops
# Print a 3x3 grid
for i in range(3):
for j in range(3):
print(f"({i}, {j})", end=" ")
print() # Newline after each row
# Output:
# (0, 0) (0, 1) (0, 2)
# (1, 0) (1, 1) (1, 2)
# (2, 0) (2, 1) (2, 2)
The while Loop: Repeat Until a Condition
Use while when you don’t know ahead of time how many loops you need:
# Keep looping while condition is true
count = 0
while count < 5:
print(count)
count += 1
# Output: 0 1 2 3 4
# Loop until something happens
password = ""
while password != "secret":
password = input("Enter password: ")
if password == "secret":
print("Access granted!")
else:
print("Try again")
break and continue
# break: exit the loop immediately
for i in range(10):
if i == 5:
break # Stop the loop
print(i)
# Output: 0 1 2 3 4
# continue: skip to next iteration
for i in range(5):
if i == 2:
continue # Skip this iteration
print(i)
# Output: 0 1 3 4 (skips 2)
When to Use for vs while
- Use
forwhen you know (or can count) how many times to loop - Use
whilewhen you loop until a condition becomes false
Part 3: Functions - Reusable Code
A function is like a recipe: you write the steps once, then use it many times. Functions make code DRY (Don’t Repeat Yourself).
The Basics
# Define a function with def
def greet(name):
# name is a parameter
return f"Hello, {name}!"
# Call it (use it)
result = greet("Alice")
print(result) # "Hello, Alice!"
# Another example
def add(a, b):
return a + b
print(add(3, 5)) # 8
print(add(10, 20)) # 30
Return Values
A function can return a value or nothing:
# Return a value
def double(x):
return x * 2
result = double(5)
print(result) # 10
# No return = returns None
def print_twice(text):
print(text)
print(text)
# No return statement!
result = print_twice("Hi")
print(result) # None
Default Arguments
Provide default values so the caller doesn’t always have to pass them:
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
print(greet("Alice")) # "Hello, Alice!"
print(greet("Bob", "Hi")) # "Hi, Bob!"
print(greet("Charlie", "Hey")) # "Hey, Charlie!"
Keyword Arguments
Pass arguments by name (order doesn’t matter):
def describe_pet(name, species, age):
return f"{name} is a {age}-year-old {species}"
# Positional (order matters)
print(describe_pet("Fluffy", "cat", 3))
# Keyword (order doesn't matter)
print(describe_pet(age=5, species="dog", name="Rex"))
# Mixed
print(describe_pet("Bella", age=2, species="parrot"))
*args: Accept Any Number of Arguments
def sum_all(*args):
# args is a tuple of all arguments passed
total = 0
for num in args:
total += num
return total
print(sum_all(1, 2, 3, 4, 5)) # 15
print(sum_all(10, 20)) # 30
print(sum_all(7)) # 7
**kwargs: Accept Keyword Arguments
def print_info(**kwargs):
# kwargs is a dict of all keyword arguments
for key, value in kwargs.items():
print(f"{key}: {value}")
print_info(name="Alice", age=25, city="NYC")
# Output:
# name: Alice
# age: 25
# city: NYC
First-Class Functions: Passing Functions as Arguments
In Python, functions are first-class citizens: you can pass them to other functions!
def apply_twice(func, value):
# func is a function, not a value
return func(func(value))
def double(x):
return x * 2
result = apply_twice(double, 3)
print(result) # double(double(3)) = 12
# Lambda: anonymous function (one-liner)
result = apply_twice(lambda x: x + 1, 5)
print(result) # 7
Pure Functions vs Side Effects
A pure function always returns the same output for the same input and doesn’t change anything in the world. A function with side effects does other things (print, modify global variables, etc.):
# Pure function: same input = same output, no side effects
def add(a, b):
return a + b # Only returns
# Side effect: changes the world (prints)
def add_and_print(a, b):
result = a + b
print(result) # Side effect!
return result
# Side effect: modifies global state
counter = 0
def increment_counter():
global counter
counter += 1 # Side effect: changes global variable
return counter
For AI engineering, pure functions are your friend. They’re easier to test, reason about, and debug.
Scope: Local vs Global
Variables have scope: where they exist and can be used.
x = 10 # Global scope
def change_x():
x = 5 # Local scope-doesn't touch the global x
print(x)
change_x() # Prints 5
print(x) # Still 10! The global x is unchanged
# To modify global, use 'global' keyword
def really_change_x():
global x
x = 20
really_change_x()
print(x) # Now 20
Part 4: Classes & OOP - Organizing Code
A class is a blueprint for objects. OOP (Object-Oriented Programming) lets you organize code into reusable, logical chunks.
The Analogy: Blueprint vs House
- A class is like a blueprint for a house (describes rooms, size, layout)
- An object (instance) is an actual house built from that blueprint
- You can build 100 houses from one blueprint; each is different (different furniture, paint color), but they all follow the same blueprint
Basic Class
class Dog:
def __init__(self, name, age):
# __init__ = constructor
# Runs when you create a new Dog
# self = the object being created
self.name = name # Instance attribute
self.age = age
def bark(self):
# Instance method (function inside a class)
# Takes self as first parameter
return f"{self.name} says woof!"
# Create instances (actual objects)
dog1 = Dog("Buddy", 5)
dog2 = Dog("Max", 3)
print(dog1.name) # "Buddy"
print(dog2.name) # "Max"
print(dog1.bark()) # "Buddy says woof!"
print(dog2.bark()) # "Max says woof!"
Instance Attributes vs Class Attributes
class Dog:
species = "Canis familiaris" # Class attribute
# ^ Shared by ALL Dogs
def __init__(self, name):
self.name = name # Instance attribute
# ^ Unique to each Dog
dog1 = Dog("Buddy")
dog2 = Dog("Max")
print(dog1.name) # "Buddy"
print(dog2.name) # "Max"
print(dog1.species) # "Canis familiaris" (same for all)
print(dog2.species) # "Canis familiaris" (same for all)
Instance Methods, Class Methods, Static Methods
class Dog:
species = "Canis familiaris"
def __init__(self, name):
self.name = name
def bark(self):
# Instance method: uses self (the specific dog)
return f"{self.name} barks!"
@classmethod
def create_from_dict(cls, data):
# Class method: uses cls (the class)
# Useful for creating objects in different ways
return cls(name=data["name"])
@staticmethod
def info():
# Static method: doesn't use self or cls
# Just a function that lives in the class
return "Dogs are loyal animals"
# Usage
dog = Dog("Buddy")
print(dog.bark()) # "Buddy barks!"
dog2 = Dog.create_from_dict({"name": "Rex"})
print(dog2.name) # "Rex"
print(Dog.info()) # "Dogs are loyal animals"
Inheritance: Code Reuse via “IS-A”
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name} makes a sound"
class Dog(Animal):
# Dog inherits from Animal
# Dog automatically has name and speak()
pass
class Cat(Animal):
pass
dog = Dog("Buddy")
cat = Cat("Whiskers")
print(dog.speak()) # "Buddy makes a sound"
print(cat.speak()) # "Whiskers makes a sound"
Method Overriding
Replace a parent method with a child version:
class Animal:
def speak(self):
return "Generic sound"
class Dog(Animal):
def speak(self):
# Override: replace parent's speak with dog-specific
return "Woof!"
class Cat(Animal):
def speak(self):
return "Meow!"
dog = Dog()
cat = Cat()
print(dog.speak()) # "Woof!" (Dog's version)
print(cat.speak()) # "Meow!" (Cat's version)
@property: Computed Attributes
Make a method look like an attribute:
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
# Looks like self.area, but it's computed on the fly
return 3.14159 * self.radius ** 2
circle = Circle(5)
print(circle.area) # 78.54975 (computed, not stored)
circle.radius = 10
print(circle.area) # 314.159 (automatically recomputed)
@abstractmethod: Enforce Subclass Implementation
Force all subclasses to implement certain methods:
from abc import ABC, abstractmethod
class Animal(ABC):
@abstractmethod
def speak(self):
# Subclasses MUST implement this
pass
class Dog(Animal):
def speak(self):
return "Woof!"
dog = Dog()
print(dog.speak()) # "Woof!"
# This would fail:
# animal = Animal() # TypeError: can't instantiate abstract class
Dunder Methods: Magic Methods
Special methods that make your class work with Python’s built-ins:
class Dog:
def __init__(self, name, age):
self.name = name
self.age = age
def __str__(self):
# str(dog) uses this
# Human-readable
return f"Dog named {self.name}"
def __repr__(self):
# repr(dog) uses this
# For debugging, more detailed
return f"Dog(name={self.name!r}, age={self.age})"
def __len__(self):
# len(dog) uses this
return self.age
def __eq__(self, other):
# dog1 == dog2 uses this
return self.name == other.name and self.age == other.age
def __add__(self, other):
# dog1 + dog2 uses this
# Create a new dog with combined names
return Dog(self.name + other.name, max(self.age, other.age))
dog1 = Dog("Buddy", 5)
dog2 = Dog("Max", 3)
print(str(dog1)) # "Dog named Buddy"
print(repr(dog1)) # "Dog(name='Buddy', age=5)"
print(len(dog1)) # 5
print(dog1 == dog2) # False
dog3 = dog1 + dog2
print(dog3.name) # "BuddyMax"
Composition vs Inheritance: “HAS-A” vs “IS-A”
Inheritance: “Dog IS-A Animal”
class Animal:
def breathe(self):
return "Breathing..."
class Dog(Animal):
# Dog inherits breathe() from Animal
pass
dog = Dog()
print(dog.breathe()) # "Breathing..."
Composition: “Dog HAS-A Tail”
class Tail:
def wag(self):
return "Wagging..."
class Dog:
def __init__(self):
self.tail = Tail() # Dog HAS a Tail
dog = Dog()
print(dog.tail.wag()) # "Wagging..."
When to use:
- Inheritance if “IS-A” relationship (Dog IS-A Animal)
- Composition if “HAS-A” relationship (Dog HAS-A Tail)
The Project: Gradient Descent from Scratch
Now you’ll build a real machine learning model using only lists, loops, functions, and classes. No NumPy, no PyTorch-just pure Python math.
What You’re Building
A LinearRegressionModel that:
- Makes predictions:
y = weight * x + bias - Measures loss: mean squared error
- Trains itself using gradient descent: a real AI algorithm
The Math (Plain English)
- Prediction: Draw a line through data. The line is
y = weight * x + bias - Loss: How far is each prediction from the actual value? Average all the errors squared.
- Gradient: “Which direction should I move weight and bias to reduce loss?” (calculus, but Python does it)
- Training: Move in that direction, repeat 100 times. The model gets better each time.
Complete Working Code
Save this as linear_regression.py:
class LinearRegressionModel:
def __init__(self, initial_weight=0.0, initial_bias=0.0):
"""
Initialize the model with weight and bias.
weight = slope of the line (how steep)
bias = y-intercept (where the line crosses y-axis)
"""
self.weight = initial_weight
self.bias = initial_bias
self.training_steps = 0
def predict(self, x):
"""
Make a prediction for a single input.
Formula: y_pred = weight * x + bias
This is just the equation of a line!
"""
return self.weight * x + self.bias
def loss(self, x_list, y_list):
"""
Calculate Mean Squared Error (MSE).
MSE = average of (prediction - actual)^2
Lower loss = better predictions.
"""
total_error = 0.0
# For each training example
for x, y in zip(x_list, y_list):
prediction = self.predict(x)
error = prediction - y
squared_error = error ** 2
total_error += squared_error
# Return the average
n = len(x_list)
return total_error / n
def train(self, x_list, y_list, lr=0.01, epochs=100):
"""
Train the model using gradient descent.
lr = learning rate (how big a step to take)
epochs = how many times to go through the data
Gradient descent: repeatedly move in the direction of lower loss.
"""
n = len(x_list)
for epoch in range(epochs):
# Calculate gradients (direction to move)
dw = 0.0 # Change in weight
db = 0.0 # Change in bias
# Go through all training examples
for x, y in zip(x_list, y_list):
prediction = self.predict(x)
error = prediction - y
# Gradient formulas for linear regression
# (These come from calculus, but we just use them)
dw += 2 * x * error
db += 2 * error
# Average the gradients
dw = dw / n
db = db / n
# Update: move opposite to the gradient
# (Opposite = towards lower loss)
self.weight -= lr * dw
self.bias -= lr * db
# Track that we did a training step
self.training_steps += 1
# Print progress every 20 epochs
if (epoch + 1) % 20 == 0:
current_loss = self.loss(x_list, y_list)
print(f"Epoch {epoch + 1}: Loss = {current_loss:.4f}")
def __str__(self):
"""
User-friendly representation.
Used by print(model)
"""
return f"LinearRegressionModel(weight={self.weight:.4f}, bias={self.bias:.4f})"
def __repr__(self):
"""
Developer-friendly representation.
Used by repr(model) for debugging
"""
return f"LinearRegressionModel(weight={self.weight}, bias={self.bias})"
def __len__(self):
"""
Return number of training steps.
Used by len(model)
"""
return self.training_steps
# Example: Train the model
if __name__ == "__main__":
# Create some sample data
# The true relationship is: y = 2x + 1 (with noise)
x_data = [1.0, 2.0, 3.0, 4.0, 5.0]
y_data = [3.1, 5.0, 7.2, 9.1, 10.9]
# Create the model
model = LinearRegressionModel()
print(f"Before training: {model}")
print()
# Train it
model.train(x_data, y_data, lr=0.01, epochs=100)
print()
# See the result
print(f"After training: {model}")
print(f"Total training steps: {len(model)}")
print()
# Make predictions
print("Predictions:")
for x in x_data:
prediction = model.predict(x)
print(f"x={x}: predicted y={prediction:.2f}")
How to Run It
python3 linear_regression.py
Expected Output
Before training: LinearRegressionModel(weight=0.0000, bias=0.0000)
Epoch 20: Loss = 8.4521
Epoch 40: Loss = 3.1842
Epoch 60: Loss = 1.5921
Epoch 80: Loss = 0.8521
Epoch 100: Loss = 0.5621
After training: LinearRegressionModel(weight=1.9821, bias=1.0342)
Total training steps: 100
Predictions:
x=1.0: predicted y=3.02
x=2.0: predicted y=5.00
x=3.0: predicted y=6.98
x=4.0: predicted y=8.97
x=5.0: predicted y=10.95
Notice: The model learned weight ≈ 2 and bias ≈ 1, which matches the true relationship y = 2x + 1!
What’s Next: Day 5 - Type Hints & Documentation
Day 5 introduces type hints: annotations that tell Python (and other programmers) what types variables and functions expect.