Week 1: Python Data Science Toolchain

Every dataset you'll touch in this course — from a scikit-learn table in Week 5 to embeddings in Week 22 — passes through the same three tools first: a reproducible Python environment, NumPy arrays, and Pandas DataFrames. This week gets all three genuinely comfortable, including the parts that quietly cause the most bugs later: broadcasting, axis direction, and missing data.

Phase 1 of 8 Week 1 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Set up and reproduce a working Python data-science environment
  • Manipulate arrays and tabular data confidently with NumPy and Pandas
  • Clean, merge and visually inspect a real dataset before modeling it

1. Setting Up Your Environment

You want an isolated, reproducible Python environment per project — so upgrading a package for one course doesn't silently break another. Two mainstream options: venv (built into Python) and conda (a separate package/environment manager popular in data science because it also handles non-Python dependencies).

terminal — venv
python3 -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate

pip install numpy pandas jupyterlab matplotlib scikit-learn
terminal — conda
conda create -n ml-course python=3.11
conda activate ml-course

conda install numpy pandas jupyterlab matplotlib scikit-learn

Either way, launch JupyterLab to get a notebook interface — the format you'll use for almost every exercise in this course, because it lets you run code in small pieces and immediately see the output (including plots) inline.

terminal
jupyter lab

Once JupyterLab opens, freeze your exact package versions into a file — this is what makes an environment reproducible rather than "works on my machine":

terminal
pip freeze > requirements.txt

# On a fresh machine, later:
pip install -r requirements.txt
Which one should you pick?

If you're not sure, use venv — it's built in, and everything in this course is pure Python packages. Reach for conda later if a future package needs system-level dependencies pip struggles with (common in some deep-learning tooling from Week 11 onward).

2. NumPy Essentials

NumPy's ndarray is the data structure underneath nearly every ML library you'll touch — Pandas, scikit-learn, and PyTorch's tensors are all conceptually extensions of it. The core idea: store numbers in contiguous memory and operate on whole arrays at once, instead of looping in Python.

numpy_basics.py
import numpy as np

a = np.array([1, 2, 3, 4])
matrix = np.array([[1, 2], [3, 4], [5, 6]])

print(a.shape)        # (4,)
print(matrix.shape)   # (3, 2)  -> 3 rows, 2 columns
print(matrix.dtype)   # int64 -- every element shares one type

# Indexing & slicing
print(matrix[0])      # [1 2]        -- first row
print(matrix[:, 1])   # [2 4 6]      -- second column, all rows

# Vectorized operations -- no explicit loop
doubled = a * 2                    # [2 4 6 8]
row_sums = matrix.sum(axis=1)      # sum across each row: [3 7 11]

That last line — matrix.sum(axis=1) — is worth sitting with: axis=0 collapses rows (sums down each column), axis=1 collapses columns (sums across each row). Getting this backwards is one of the most common early bugs in ML code, because it fails silently — you just get numbers, the wrong ones.

broadcasting
# Broadcasting: NumPy stretches smaller shapes to match, without copying data
prices = np.array([10.0, 20.0, 30.0])
tax_rate = 1.08

with_tax = prices * tax_rate   # [10.8, 21.6, 32.4] -- scalar "broadcasts" to every element

# Broadcasting also works between a matrix and a smaller array,
# as long as their trailing dimensions line up (or are 1)
matrix = np.array([[1, 2, 3], [4, 5, 6]])   # shape (2, 3)
col_means = matrix.mean(axis=0)              # shape (3,)  -> [2.5, 3.5, 4.5]

centered = matrix - col_means                # (2, 3) - (3,) broadcasts fine

Vectorized operations aren't just more concise than a Python for loop — they're typically 10–100x faster, because NumPy pushes the loop down into compiled C code. You'll rely on this constantly once datasets grow past a few thousand rows, and again in Week 11 when a "vectorized operation" becomes "a whole neural network layer."

reshaping
flat = np.arange(12)          # [0, 1, 2, ..., 11]
grid = flat.reshape(3, 4)     # same 12 numbers, viewed as 3 rows x 4 columns

# np.newaxis inserts a new length-1 axis -- useful for broadcasting shapes
column = flat[:, np.newaxis]  # shape (12, 1) instead of (12,)
Why reshape matters later

.reshape() doesn't copy data — it just reinterprets the same memory with a different shape. You'll use this constantly to turn a flat list of pixel values into an image grid in Week 13, and to add a "batch dimension" before feeding data into a PyTorch model.

3. Pandas for Tabular Data

Pandas builds on NumPy to give you labeled, tabular data — a DataFrame is essentially a spreadsheet you can manipulate in code, with a Series as its single-column building block.

pandas_basics.py
import pandas as pd

df = pd.read_csv('customers.csv')

df.head()                      # first 5 rows
df.info()                      # column names, dtypes, non-null counts
df.describe()                  # count, mean, std, min/max per numeric column

# Selecting
ages = df['age']                       # a Series (one column)
subset = df[['name', 'age', 'city']]    # a DataFrame (several columns)

# Filtering
adults = df[df['age'] >= 18]

# Grouping and aggregating
avg_age_by_city = df.groupby('city')['age'].mean()

df[df['age'] >= 18] looks unusual the first time you see it: the inner expression produces a Series of True/False values (one per row), and indexing the DataFrame with that boolean Series keeps only the rows where it's True. This "boolean mask" pattern is everywhere in Pandas code.

sorting & new columns
# Sort by a column, descending
df_sorted = df.sort_values('age', ascending=False)

# Derived columns are just assignments -- vectorized, like NumPy
df['is_adult'] = df['age'] >= 18
df['age_in_months'] = df['age'] * 12

4. Handling Missing Data & Merging Tables

Real datasets almost never arrive clean. Two skills you'll use in nearly every exercise from here on: finding and handling missing values, and combining data that lives across more than one table.

missing_data.py
df.isna().sum()                 # count of missing values per column

# Three common strategies, in increasing order of "aggressiveness":
df['age'] = df['age'].fillna(df['age'].mean())   # fill with a sensible default
df_complete = df.dropna(subset=['email'])         # drop rows missing a required field
df_complete = df.dropna()                          # drop any row with any missing value

Which strategy is right depends entirely on why the value is missing and what you'll do with the column later — filling a numeric column's gaps with its mean is a common default, but it quietly shrinks that column's variance. You'll revisit this tradeoff properly when you build a training pipeline in Week 4.

merging.py
orders = pd.read_csv('orders.csv')       # columns: order_id, customer_id, total
customers = pd.read_csv('customers.csv') # columns: customer_id, name, city

# Join on a shared key column -- like a SQL JOIN
enriched = orders.merge(customers, on='customer_id', how='left')

# how='left' keeps every order, even if a matching customer is missing
# how='inner' would drop orders with no matching customer_id
how= is the whole decision

'left' keeps every row from the first table regardless of a match; 'inner' keeps only rows that match in both; 'outer' keeps everything from both sides, filling gaps with NaN. Picking the wrong one is a quiet way to silently drop rows.

5. Exploratory Visualization with Matplotlib

Before you model anything, you look at it. A histogram catches a skewed distribution; a scatter plot catches a relationship (or a cluster of outliers) a table of numbers would hide. Matplotlib is the standard plotting library almost every other Python visualization tool builds on.

exploratory_viz.py
import matplotlib.pyplot as plt

fig, axes = plt.subplots(1, 2, figsize=(10, 4))

# Distribution of a single numeric column
axes[0].hist(df['age'], bins=20)
axes[0].set_title('Age distribution')

# Relationship between two numeric columns
axes[1].scatter(df['hours_studied'], df['score'])
axes[1].set_xlabel('Hours studied')
axes[1].set_ylabel('Score')

plt.tight_layout()
plt.show()

This kind of quick look — called exploratory data analysis (EDA) — is the very first thing you'll do with any new dataset in Week 5, before choosing a model or even deciding whether the data needs cleaning. A five-minute histogram now routinely saves an hour of debugging a model that "isn't learning" later.

6. Hands-on Exercise

Hands-on

Clean, merge and visualize a messy two-table dataset

You're given student records split across two small tables, with a few missing values thrown in — a shape you'll see constantly in real projects.

starter data
students = [
    {"student_id": 1, "name": "Ada",    "hours_studied": 5},
    {"student_id": 2, "name": "Grace",  "hours_studied": 4},
    {"student_id": 3, "name": "Alan",   "hours_studied": None},
    {"student_id": 4, "name": "Barbara","hours_studied": 6},
    {"student_id": 5, "name": "Edsger", "hours_studied": 3},
]

scores = [
    {"student_id": 1, "score": 92},
    {"student_id": 2, "score": 88},
    {"student_id": 3, "score": 76},
    {"student_id": 4, "score": 95},
    # student_id 5 is missing from this table on purpose
]

Requirements:

  1. Load both lists into two separate DataFrames.
  2. Merge them on student_id using how='left' so every student is kept, then inspect which rows now have missing values with .isna().
  3. Fill missing hours_studied values with the column's mean; decide — and comment in your notebook — whether the row with a missing score should be dropped or filled, and why.
  4. Add a score_normalized column: (score - mean) / std, using Pandas' .mean() and .std().
  5. Plot a histogram of hours_studied and a scatter plot of hours_studied vs. score. Does the scatter plot suggest a relationship?
Hint

Merge first, then check for missing values — merging can introduce new NaNs (from the unmatched student) even though neither original table looked incomplete on its own.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why are NumPy's vectorized operations so much faster than an equivalent Python for loop?

The looping happens inside compiled C code instead of the Python interpreter, and the data is stored in contiguous memory of a single type — no per-element type checks or Python object overhead the way a list of numbers has. The Python loop pays interpreter overhead on every single iteration; a vectorized call pays it once.

Q2

For a 2D array, what's the difference between .sum(axis=0) and .sum(axis=1)?

axis=0 collapses along the rows, producing one sum per column (summing "down"). axis=1 collapses along the columns, producing one sum per row (summing "across"). A useful trick: the axis number is the one that disappears from the shape after the operation.

Q3

What's the practical difference between how='left' and how='inner' in df.merge()?

'left' keeps every row from the first (left) DataFrame, filling in NaN where no match exists in the second table. 'inner' keeps only rows whose join key matches in both tables, silently dropping unmatched rows from either side — a common source of "my dataset got smaller and I don't know why" bugs.

Q4

Why look at a histogram or scatter plot before training any model on a dataset?

Summary statistics like mean and standard deviation can hide skew, multiple clusters, or outliers that a plot reveals instantly. Catching a data problem — a skewed distribution, an obviously mismatched join, an outlier that dominates the scale — during EDA is far cheaper than discovering it after a model has already "mysteriously" failed to learn in Week 5.