What’s one thing you learned? What’s still confusing?
Pandas Advanced: merge, pivot, apply, Time Series
Join DataFrames, apply functions, reshape with pivot_table, and build time series features.
Data Visualization with Matplotlib
Line plots, scatter plots, bar charts, histograms, and multi-panel figures.
Python for Machine Learning: sklearn & Beyond
The sklearn workflow: load, split, train, predict, evaluate. Pipelines and model comparison.
Interactive Labs for This Track
Loop Visualizer
You're a factory robot repeating the same task on an assembly line — watch how loops automate repetitive work
List Slicing
You have a playlist of 50 songs — grab just tracks 10 through 20 with a single slice expression
Sorting Algorithms
You're organizing a library of 10,000 books — which sorting method is fastest?
Ask questions, share insights
import numpy as np
# Creating arrays
a = np.array([1, 2, 3, 4, 5]) # from a Python list
b = np.zeros(5) # [0. 0. 0. 0. 0.]
c = np.ones(5) # [1. 1. 1. 1. 1.]
d = np.arange(0, 10, 2) # [0, 2, 4, 6, 8]
e = np.linspace(0, 1, 5) # [0. 0.25 0.5 0.75 1. ]
f = np.random.randn(5) # 5 random numbers (normal distribution)
print(f"Array: {a}")
print(f"Shape: {a.shape}") # (5,)
print(f"Data type: {a.dtype}") # int64
print(f"Dimensions: {a.ndim}") # 1# 2D array -- a matrix
matrix = np.array([
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]
])
print(f"Shape: {matrix.shape}") # (3, 3)
print(f"Dimensions: {matrix.ndim}") # 2
print(f"Total elements: {matrix.size}") # 9
# Zeros and ones matrices
zeros = np.zeros((3, 4)) # 3 rows, 4 columns of zeros
ones = np.ones((2, 3)) # 2 rows, 3 columns of ones
identity = np.eye(3) # 3x3 identity matrix
random_matrix = np.random.randn(3, 3) # 3x3 random valuesNumPy slicing is like Python list slicing but extended to multiple dimensions:
arr = np.array([10, 20, 30, 40, 50, 60, 70, 80, 90])
# Basic indexing (same as lists)
print(arr[0]) # 10
print(arr[-1]) # 90
print(arr[2:5]) # [30, 40, 50]
# 2D indexing
matrix = np.array([
[1, 2, 3, 4],
[5, 6, 7, 8],
[9, 10, 11, 12]
])
print(matrix[0, 0]) # 1 (row 0, column 0)
print(matrix[1, 2]) # 7 (row 1, column 2)
print(matrix[0]) # [1, 2, 3, 4] (entire row 0)
print(matrix[:, 0]) # [1, 5, 9] (entire column 0)
print(matrix[0:2, 1:3]) # [[2, 3], [6, 7]] (submatrix)
# Boolean indexing (filtering)
scores = np.array([85, 92, 78, 95, 88, 76, 91])
high_scores = scores[scores >= 90] # [92, 95, 91]
print(f"Scores >= 90: {high_scores}")
# Fancy indexing
indices = np.array([0, 2, 4])
selected = scores[indices] # [85, 78, 88]
print(f"Selected: {selected}")a = np.array([1, 2, 3, 4, 5])
b = np.array([10, 20, 30, 40, 50])
# Element-wise operations (no loops needed!)
print(a + b) # [11, 22, 33, 44, 55]
print(a * b) # [10, 40, 90, 160, 250]
print(a ** 2) # [1, 4, 9, 16, 25]
print(np.sqrt(a)) # [1. 1.41 1.73 2. 2.24]
# Broadcasting -- operations between different shapes
scores = np.array([85, 92, 78, 95, 88])
# Add 5 bonus points to every score (scalar + array)
curved = scores + 5
print(f"Curved: {curved}") # [90, 97, 83, 100, 93]
# Normalize to 0-1 range
normalized = (scores - scores.min()) / (scores.max() - scores.min())
print(f"Normalized: {normalized}")
# [0.41 0.82 0. 1. 0.59]What's faster: a Python for loop or NumPy vectorized operation?
import time
size = 1_000_000
python_list = list(range(size))
numpy_array = np.arange(size)
# Python loop
start = time.time()
result_loop = [x * 2 + 1 for x in python_list]
loop_time = time.time() - start
# NumPy vectorized
start = time.time()
result_numpy = numpy_array * 2 + 1
numpy_time = time.time() - start
print(f"Python loop: {loop_time:.4f}s")
print(f"NumPy: {numpy_time:.4f}s")
print(f"NumPy is {loop_time / numpy_time:.0f}x faster!")
# Typical output: NumPy is 50-100x faster!a = np.arange(12) # [0, 1, 2, ..., 11]
# Reshape to 3 rows, 4 columns
reshaped = a.reshape(3, 4)
print(reshaped)
# [[ 0, 1, 2, 3],
# [ 4, 5, 6, 7],
# [ 8, 9, 10, 11]]
# Reshape to 2 rows, -1 means "figure out the other dimension"
reshaped2 = a.reshape(2, -1)
print(reshaped2.shape) # (2, 6)
# Flatten back to 1D
flat = reshaped.flatten()
print(flat) # [0, 1, 2, ..., 11]
# Transpose
print(reshaped.T) # swap rows and columns
print(reshaped.T.shape) # (4, 3)data = np.array([85, 92, 78, 95, 88, 76, 91, 83, 97, 80])
print(f"Mean: {np.mean(data):.1f}") # 86.5
print(f"Median: {np.median(data):.1f}") # 86.5
print(f"Std: {np.std(data):.1f}") # 6.9
print(f"Min: {np.min(data)}") # 76
print(f"Max: {np.max(data)}") # 97
print(f"Sum: {np.sum(data)}") # 865
print(f"Argmax: {np.argmax(data)}") # 8 (index of max value)
# Aggregation along axes (for 2D arrays)
grades = np.array([
[85, 90, 78], # Student 1: math, science, english
[92, 88, 95], # Student 2
[76, 82, 89], # Student 3
])
print(f"Average per student: {np.mean(grades, axis=1)}") # [84.3, 91.7, 82.3]
print(f"Average per subject: {np.mean(grades, axis=0)}") # [84.3, 86.7, 87.3]Pandas is built on top of NumPy and adds labeled rows/columns, making data manipulation intuitive:
import pandas as pd
# Create a DataFrame from a dictionary
df = pd.DataFrame({
"name": ["Alice", "Bob", "Charlie", "Diana", "Eve"],
"age": [17, 16, 18, 17, 16],
"math": [95, 87, 92, 78, 99],
"science": [88, 91, 85, 82, 94],
"english": [92, 78, 88, 90, 85],
})
print(df)
# name age math science english
# 0 Alice 17 95 88 92
# 1 Bob 16 87 91 78
# 2 Charlie 18 92 85 88
# 3 Diana 17 78 82 90
# 4 Eve 16 99 94 85
# Basic info
print(f"\nShape: {df.shape}") # (5, 5)
print(f"Columns: {list(df.columns)}") # ['name', 'age', 'math', ...]
print(f"\n{df.dtypes}") # data types per column
print(f"\n{df.describe()}") # summary statistics# Select a single column (returns a Series)
names = df["name"]
print(names)
# Select multiple columns (returns a DataFrame)
scores = df[["math", "science", "english"]]
print(scores)
# Select rows by index
print(df.iloc[0]) # first row (by integer position)
print(df.iloc[0:3]) # first 3 rows
# Select by label
df_indexed = df.set_index("name")
print(df_indexed.loc["Alice"]) # Alice's row
print(df_indexed.loc["Alice", "math"]) # Alice's math score: 95# Boolean filtering -- like NumPy but with labeled data
high_math = df[df["math"] >= 90]
print("Math >= 90:")
print(high_math)
# Multiple conditions (use & for AND, | for OR, ~ for NOT)
smart_young = df[(df["math"] >= 90) & (df["age"] <= 17)]
print("\nMath >= 90 AND age <= 17:")
print(smart_young)
# Filter by string
alice = df[df["name"] == "Alice"]
print(f"\nAlice's data:\n{alice}")# Calculate average score
df["average"] = (df["math"] + df["science"] + df["english"]) / 3
df["average"] = df["average"].round(1)
# Categorize students
df["grade_letter"] = pd.cut(
df["average"],
bins=[0, 60, 70, 80, 90, 100],
labels=["F", "D", "C", "B", "A"]
)
print(df[["name", "average", "grade_letter"]])# Sort by a column
df_sorted = df.sort_values("average", ascending=False)
print("Ranked by average:")
print(df_sorted[["name", "average"]])
# Sort by multiple columns
df_multi = df.sort_values(["age", "average"], ascending=[True, False])# Group by age and calculate statistics
age_groups = df.groupby("age")["average"].agg(["mean", "count", "max"])
print("Stats by age:")
print(age_groups)
# More complex groupby
report = df.groupby("age").agg({
"math": "mean",
"science": "mean",
"english": "mean",
"name": "count"
}).rename(columns={"name": "student_count"})
print("\nDetailed report by age:")
print(report.round(1))# Create data with missing values
df_messy = pd.DataFrame({
"name": ["Alice", "Bob", "Charlie", "Diana"],
"score": [95, None, 88, None],
"grade": ["A", "B", None, "C"],
})
# Check for missing values
print(df_messy.isnull().sum()) # count NaN per column
# Fill missing values
df_filled = df_messy.fillna({"score": df_messy["score"].mean(), "grade": "N/A"})
print(df_filled)
# Drop rows with any missing values
df_clean = df_messy.dropna()
print(f"Rows after dropping NaN: {len(df_clean)}")# Read a CSV file into a DataFrame
# df = pd.read_csv("students.csv")
# Common options
# df = pd.read_csv("data.csv", sep=";", encoding="utf-8", index_col=0)
# df = pd.read_csv("big_file.csv", nrows=1000) # read first 1000 rows only
# Quick exploration workflow
# print(df.head()) # first 5 rows
# print(df.info()) # column types and non-null counts
# print(df.describe()) # summary statistics
# print(df.value_counts("column_name")) # frequency countsimport numpy as np
import pandas as pd
# Create a mock student grades dataset
np.random.seed(42)
n_students = 100
data = {
"student_id": [f"STU_{i:03d}" for i in range(n_students)],
"class": np.random.choice(["9A", "9B", "9C"], n_students),
"math": np.random.normal(75, 12, n_students).clip(0, 100).astype(int),
"science": np.random.normal(78, 10, n_students).clip(0, 100).astype(int),
"english": np.random.normal(80, 11, n_students).clip(0, 100).astype(int),
"attendance_pct": np.random.normal(90, 8, n_students).clip(50, 100).round(1),
}
df = pd.DataFrame(data)
# Calculate average score
df["avg_score"] = df[["math", "science", "english"]].mean(axis=1).round(1)
# Assign letter grades
df["letter_grade"] = pd.cut(
df["avg_score"],
bins=[0, 60, 70, 80, 90, 100],
labels=["F", "D", "C", "B", "A"]
)
# Analysis 1: Class-level performance
print("=== CLASS PERFORMANCE ===")
class_report = df.groupby("class").agg({
"avg_score": ["mean", "std"],
"student_id": "count"
}).round(1)
print(class_report)
# Analysis 2: Correlation between attendance and scores
print("\n=== ATTENDANCE vs PERFORMANCE ===")
high_attendance = df[df["attendance_pct"] >= 90]
low_attendance = df[df["attendance_pct"] < 90]
print(f"Avg score (attendance >= 90%): {high_attendance['avg_score'].mean():.1f}")
print(f"Avg score (attendance < 90%): {low_attendance['avg_score'].mean():.1f}")
# Analysis 3: Top and bottom students
print("\n=== TOP 5 STUDENTS ===")
top5 = df.nlargest(5, "avg_score")[["student_id", "class", "avg_score", "letter_grade"]]
print(top5.to_string(index=False))
print("\n=== STUDENTS NEEDING HELP (avg < 65) ===")
struggling = df[df["avg_score"] < 65][["student_id", "class", "avg_score"]]
print(f"Count: {len(struggling)}")
print(struggling.to_string(index=False))
# Analysis 4: Subject difficulty
print("\n=== SUBJECT DIFFICULTY ===")
for subject in ["math", "science", "english"]:
mean = df[subject].mean()
fail_rate = (df[subject] < 60).mean() * 100
print(f" {subject:>7}: avg = {mean:.1f}, fail rate = {fail_rate:.1f}%")Tests · Analyze the student dataset, calculate statistics, and verify your results!
In machine learning, your data almost always arrives as multi-dimensional arrays. Here's the most common shapes you'll encounter:
import numpy as np
# Images are 3D arrays: (height, width, channels)
image = np.zeros((28, 28)) # Grayscale MNIST digit (28×28 pixels)
rgb = np.zeros((224, 224, 3)) # RGB image (224×224, 3 colour channels)
# A batch of 32 images for training:
batch = np.zeros((32, 28, 28)) # 32 grayscale images at once
print(batch.shape) # (32, 28, 28)
print(batch[0].shape) # (28, 28) — first image
# Embeddings are 1D or 2D vectors
word_vec = np.random.randn(512) # One word embedding (512 dims)
sentence_mat = np.random.randn(100, 512) # 100 words × 512 dims
# Reshaping — fundamental in ML pipelines
flat = image.reshape(-1) # (784,) — flatten for a dense layer
back = flat.reshape(28, 28) # (28, 28) — back to image(batch, height, width, channels). Understanding array shapes is the #1 skill for debugging ML model errors like RuntimeError: Expected input size.Interactive Lab
Visualize how NumPy arrays transform and combine — the math behind every ML model
df[df["score"] >= 90] instantly filters to matching rows. Combine conditions with & (AND) and | (OR) for complex queriesdf.groupby("category")["value"].mean() splits data into groups, applies a function to each group, and combines the results. This pattern solves most analysis questionsWhy are NumPy operations faster than Python for loops?
axis=0 means "collapse rows" (results have shape matching columns). axis=1 means "collapse columns" (results have shape matching rows). When in doubt, check .shape before and after the operation.