Python for Machine Learning

Week 1 of 16 · Foundations · 7 days

Full curriculum
Week 01 · Foundations

Python for Machine Learning

Week 01 · Day 1 of 7

What Machine Learning Actually Is

Supervised, unsupervised, and the one idea the whole field rests on

By 798 words

Most programming you have done so far is instructional. You knew the rule, add tax at 20 percent, reject an order under £5, and you wrote it down. Machine learning inverts that. You have the answers and you want the rule, and the rule is one nobody can state precisely.

Nobody can write the condition for “this customer is about to leave”. But a company has thousands of customers who did leave and thousands who stayed, and the difference is in the data somewhere. A learning algorithm searches for it.

Machine learning: Fitting a function from data rather than specifying it by hand. You supply examples of inputs and their correct outputs, and the algorithm searches a space of candidate functions for one that reproduces them and, critically, keeps working on examples it has never seen.

The three families you will meet

Almost everything in this course sits in one of three boxes. Knowing which box a problem belongs to decides which algorithms are even candidates.

FamilyYou give itIt learns toExample
SupervisedInputs and known answersPredict the answer for new inputsWill this customer churn?
UnsupervisedInputs onlyFind structure, groups, axes, odditiesWhich customers behave alike?
ReinforcementAn environment and a rewardChoose actions that pay off over timeWhich offer to show, learning as you go

Weeks 4 to 7 are supervised learning, weeks 9 and 10 are unsupervised. Reinforcement learning is a course of its own and we only sketch it.

Supervised splits again, by what you are predicting

Inside supervised learning the type of the answer decides the metric, the loss function and often the algorithm:

  • Regression: the answer is a number on a continuous scale. How much will this customer spend next quarter?
  • Classification: the answer is one of a fixed set of labels. Churn or stay. Fraud or legitimate. Which of nine defect types.

The same dataset can pose both. monthly_charges is a regression target; churned is a classification target. You will use both this course.

The one idea that separates ML from statistics

A statistician asks whether a relationship is real. A machine learning practitioner asks whether a model will work on data it has not seen. That second question has a name, and it is the discipline the whole field is built on.

Generalisation: How well a model performs on data that played no part in fitting it. A model that scores perfectly on its training data and poorly on new data has memorised rather than learned. Every technique in weeks 4 through 7 exists to detect or prevent that.

The mistake that ruins most first projects

You fit a model, score it on the same rows you fitted it on, and get 99 percent. It feels like success. It is the machine learning equivalent of marking your own exam with the answer sheet open. Any model complex enough can memorise its training set. Week 7 is devoted to measuring this honestly; until then, treat every score computed on training data as meaningless.

What you need installed

Python 3.10 or newer, and a handful of libraries. Install Python from python.org and tick Add Python to PATH on Windows, or use your package manager on Linux and macOS. Check it took:

python --version
pip --version

Work in a virtual environment, always

A virtual environment is a private copy of Python's package folder for one project. Without one, every project on your machine shares a single set of library versions, and the day a project needs an older scikit‑learn you break the other five. Create one per project:

python -m venv .venv

# Windows
.venv\Scripts\activate

# macOS and Linux
source .venv/bin/activate

Your prompt gains a (.venv) prefix. That prefix is the whole point: it tells you which Python you are about to run. Now install the stack:

pip install numpy pandas scikit-learn matplotlib seaborn jupyterlab

Pin what you installed

Run pip freeze > requirements.txt once the environment works. That file records exact versions, so pip install -r requirements.txt reproduces it on another machine, or on yours in six months, when the newest scikit-learn has renamed the argument your code passes.

How you will actually work

Two tools, two jobs. Use a notebook while you are exploring, because you want to see each intermediate result. Move to a .py file the moment the code matters, because notebooks hide execution order and a notebook that only runs if you press the cells in the right sequence is not reproducible.

jupyter lab

Day 1 takeaway

Machine learning fits a rule from examples instead of you stating it. Supervised learning has the answers, unsupervised does not, and within supervised the answer being a number or a label decides almost everything downstream. The measure that matters is never performance on data the model has already seen.
Week 01 · Day 2 of 7

NumPy and Vectorised Thinking

Arrays, shapes, broadcasting and why loops are the wrong habit

By 715 words

Every machine learning library in Python stands on NumPy. A scikit-learn model does not really take a DataFrame; it takes an array of numbers, and pandas hands one over on request. TensorFlow tensors follow NumPy's rules deliberately. Learn the array and the rest of the stack stops feeling arbitrary.

Why not a plain Python list

A Python list holds pointers to objects scattered across memory, each one carrying its own type information. A NumPy array holds raw numbers of one type packed end to end. That single difference buys both compactness and speed, because the loop that adds them runs in C instead of in the interpreter.

import numpy as np

py_list = list(range(1_000_000))
arr = np.arange(1_000_000)

print(type(arr), arr.dtype, arr.shape)
print('list bytes:', py_list.__sizeof__())
print('array bytes:', arr.nbytes)
<class 'numpy.ndarray'> int64 (1000000,)
list bytes: 8000040
array bytes: 8000000

The sizes look similar, but the list figure counts only the pointers. Each of the million integers is a separate object of roughly 28 bytes on top of that. The array figure is the whole story.

Vectorised thinking

The habit to build this week: stop writing loops over rows. An operation written against a whole array is shorter, faster and less likely to be wrong.

import numpy as np
import time

charges = np.random.default_rng(0).uniform(20, 120, 1_000_000)

t = time.perf_counter()
loop = [c * 1.2 for c in charges]
loop_time = time.perf_counter() - t

t = time.perf_counter()
vec = charges * 1.2
vec_time = time.perf_counter() - t

print(f'loop {loop_time:4f}s')
print(f'vectorised {vec_time:4f}s')
print(f'speedup {loop_time / vec_time:0f}x')
loop 0.083917s
vectorised 0.002325s
speedup 36.093464x

Forty-something times, for arithmetic. On the operations that matter in model fitting the gap is wider still, because those call into optimised linear algebra libraries.

Shape is the thing you will get wrong

Nearly every confusing error in scikit‑learn and TensorFlow is a shape error. Build the habit now of printing .shape whenever something surprises you.

import numpy as np

a = np.array([1, 2, 3]) # 1-D, shape (3,)
b = np.array([[1, 2, 3]]) # 2-D, one row
c = np.array([[1], [2], [3]]) # 2-D, one column

for name, x in [('a', a), ('b', b), ('c', c)]:
print(name, x.shape, x.ndim)
a (3,) 1
b (1, 3) 2
c (3, 1) 2

(3,) is not (3, 1)

A one-dimensional array of three numbers and a column of three numbers are different objects, and scikit-learn cares. Fitting on a 1-D feature array raises Expected 2D array, got 1D array instead. The fix is x.reshape(-1, 1), where -1 means work out this dimension from the length.

Broadcasting

When you combine arrays of different shapes, NumPy stretches the smaller one to fit, if the shapes are compatible. Compatible means: compare dimensions from the right, and each pair must be equal or one of them must be 1.

import numpy as np

X = np.array([[10.0, 200.0],
[12.0, 240.0],
[11.0, 100.0]])

col_mean = X.mean(axis=0) # shape (2,) -- one mean per column
centred = X - col_mean # (3, 2) - (2,) broadcasts down the rows

print('means ', col_mean)
print('centred\n', centred)
print('new means', centred.mean(axis=0).round(10))
means [ 11. 180.]
centred
[[ -1. 20.]
[ 1. 60.]
[ 0. -80.]]
new means [0. 0.]

That is standardisation, in one line, and it is exactly what StandardScaler does in week 8. axis=0 means “collapse the rows, keep the columns”, which is the direction you almost always want, because columns are features.

Boolean masks

Selecting rows by condition is the operation you will reach for constantly.

import numpy as np

charges = np.array([25.0, 88.5, 104.0, 19.0, 71.2])
high = charges > 70

print('mask ', high)
print('values ', charges[high])
print('count ', high.sum()) # True counts as 1
print('share ', high.mean()) # so the mean is the proportion
mask [False True True False True]
values [ 88.5 104. 71.2]
count 3
share 0.6

mask.mean() is a proportion

Because True is 1 and False is 0, the mean of a boolean array is the fraction of elements that satisfy the condition. (y == 1).mean() is your churn rate. You will use this constantly and it saves defining a counter.

Day 2 takeaway

Arrays are typed, contiguous and fast, and the whole ML stack assumes them. Write operations against whole arrays rather than looping. Print .shape when confused, remember that (3,) and (3, 1) are different, and use axis=0 to work down columns.
Week 01 · Day 3 of 7

pandas and the Course Dataset

Series, DataFrames, group-by, and generating the data you will use

By 1013 words

NumPy gives you a grid of one type. Real data is a table with a name and a type per column, missing values, and dates. That is pandas.

Series and DataFrame

A Series is one column: values plus an index. A DataFrame is a dictionary of Series sharing that index.

import pandas as pd

s = pd.Series([25.0, 88.5, 104.0], name='monthly_charges')
print(s)
print('dtype:', s.dtype)

df = pd.DataFrame({
'customer_id': ['C1', 'C2', 'C3'],
'monthly_charges': [25.0, 88.5, 104.0],
'contract': ['Month-to-month', 'Two year', 'Month-to-month'],
})
print(df)
print(df.dtypes)
0 25.0
1 88.5
2 104.0
Name: monthly_charges, dtype: float64
dtype: float64
customer_id monthly_charges contract
0 C1 25.0 Month-to-month
1 C2 88.5 Two year
2 C3 104.0 Month-to-month
customer_id object
monthly_charges float64
contract object
dtype: object

object means pandas gave up

A dtype of object means the column holds Python objects, usually strings. That is right for contract. It is a bug for anything you intend to do arithmetic on. When a column you expected to be numeric shows as object, one value in it is not a number, and you will meet exactly that tomorrow.

Selecting: loc and iloc

Two accessors, and mixing them up is the most common pandas error. .loc selects by label, .iloc by integer position.

import pandas as pd

df = pd.DataFrame({
'tenure': [3, 40, 12, 65],
'charges': [70.0, 55.5, 99.9, 21.0],
'churned': [1, 0, 1, 0],
}, index=['a', 'b', 'c', 'd'])

print(df.loc['b', 'charges']) # label row, label column
print(df.iloc[1, 1]) # second row, second column
print(df.loc[df['churned'] == 1, ['tenure', 'charges']])
55.5
55.5
tenure charges
a 3 70.0
c 12 99.9

That last line is the pattern you will use every day: a boolean condition picks the rows, a list of names picks the columns.

Grouping answers most questions

“Does churn differ by contract type?” is a group-by.

import pandas as pd

df = pd.DataFrame({
'contract': ['Month-to-month'] * 4 + ['Two year'] * 4,
'churned': [1, 1, 0, 1, 0, 0, 0, 1],
'charges': [70, 82, 65, 90, 55, 40, 61, 58],
})

summary = df.groupby('contract').agg(
customers=('churned', 'size'),
churn_rate=('churned', 'mean'),
avg_charge=('charges', 'mean'),
)
print(summary.round(3))
customers churn_rate avg_charge
contract
Month-to-month 4 0.75 76.75
Two year 4 0.25 53.50

Named aggregation, new_name=('column', 'function'), is worth learning properly. It produces flat, readable column names instead of the nested mess the older syntax gives you.

Make pandas print the whole frame

By default pandas folds wide tables and replaces the middle columns with an ellipsis, so the output you see depends on how wide your terminal happens to be. Set these two options once at the top of every notebook. Every printed result in this course was produced with them set, so your output will match what these pages show.

import pandas as pd

pd.set_option('display.width', 100)
pd.set_option('display.max_columns', 25)
print('pandas', pd.__version__)
pandas 2.2.3

Build the dataset for the rest of the course

Public churn datasets move, vanish behind logins, or change shape. So you will generate yours. Save this as make_dataset.py next to your notebook and run it once. It is deterministic: the same seed gives everyone the same file, so the numbers in these pages are the numbers you will see.

import numpy as np
import pandas as pd

rng = np.random.default_rng(42)
n = 3000

contract = rng.choice(['Month-to-month', 'One year', 'Two year'],
size=n, p=[0.55, 0.25, 0.20])
internet = rng.choice(['Fibre optic', 'DSL', 'No internet'], size=n,
p=[0.44, 0.34, 0.22])
payment = rng.choice(['Electronic check', 'Mailed check',
'Bank transfer', 'Credit card'], size=n,
p=[0.34, 0.23, 0.22, 0.21])
dependents = rng.choice(['Yes', 'No'], size=n, p=[0.3, 0.7])

# Two-year customers have by definition been around longer.
base = np.where(contract == 'Two year', 42,
np.where(contract == 'One year', 28, 16))
tenure = np.clip(rng.gamma(2.2, base / 2.2), 1, 72).round().astype(int)

price = np.where(internet == 'Fibre optic', 79,
np.where(internet == 'DSL', 55, 21))
monthly = np.clip(rng.normal(price, 9), 15, 125).round(2)
support = rng.poisson(np.where(internet == 'Fibre optic', 1.6, 0.9))

# The rule a model is meant to recover.
logit = (-2.8
+ 1.55 * (contract == 'Month-to-month')
- 0.85 * (contract == 'Two year')
- 0.055 * tenure
+ 0.021 * monthly
+ 0.30 * support
+ 0.42 * (payment == 'Electronic check')
- 0.25 * (dependents == 'Yes')
+ rng.normal(0, 0.45, n))
churned = (1 / (1 + np.exp(-logit)) > rng.uniform(size=n)).astype(int)

total = (monthly * tenure * rng.uniform(0.94, 1.06, n)).round(2)
start = np.datetime64('2019-01', 'M')
signup = (start + (72 - tenure).astype('timedelta64[M]')).astype('datetime64[D]')

df = pd.DataFrame({
'customer_id': ['C%05d' % i for i in range(1, n + 1)],
'signup_date': pd.to_datetime(signup).astype(str),
'tenure_months': tenure,
'contract': contract,
'internet_service': internet,
'payment_method': payment,
'has_dependents': dependents,
'support_calls': support,
'monthly_charges': monthly,
'total_charges': total,
'churned': churned,
})

# --- and now spoil it, the way collection genuinely spoils data ---
fresh = df.index[df['tenure_months'] <= 1]
df.loc[fresh, 'total_charges'] = np.nan
# A space, not an empty string: pandas reads '' as missing, which would
# hide the very defect this is meant to reproduce.
df['total_charges'] = df['total_charges'].map(
lambda v: ' ' if pd.isna(v) else ('%.2f' % v))

drift = rng.choice(df.index, size=int(n * 0.07), replace=False)
df.loc[drift, 'contract'] = df.loc[drift, 'contract'].map(
lambda s: rng.choice([s.upper(), s.lower(), ' ' + s, s + ' ']))

for col, frac in (('has_dependents', 0.06), ('monthly_charges', 0.055)):
gaps = rng.choice(df.index, size=int(n * frac), replace=False)
df.loc[gaps, col] = np.nan

df.loc[df.index[7], 'support_calls'] = 97
df = pd.concat([df, df.iloc[500:638]], ignore_index=True)
df = df.sample(frac=1, random_state=42).reset_index(drop=True)

df.to_csv('customers.csv', index=False)
print('customers.csv:', df.shape, 'churn rate %.1f%%' % (df['churned'].mean() * 100))
customers.csv: (3138, 11) churn rate 26.8%

The mess is on purpose

Blank totals, four spellings of one contract type, 138 duplicated rows, one customer with 97 support calls and gaps in two columns. Every one of those is a defect you will meet in real extracts, and every one teaches something specific in the weeks ahead. Cleaning it is tomorrow.

Day 3 takeaway

A DataFrame is named, typed columns over a shared index. Select with .loc by label and .iloc by position, filter rows with a boolean condition, and answer comparison questions with groupby and named aggregation. You now have customers.csv, which every later week builds on.
Week 01 · Day 4 of 7

First Contact With Messy Data

Dtypes that lie, categories that multiply, duplicates and gaps

By 756 words

You have a CSV. Before any modelling, you need to know what is actually in it, and the answer is never what the column names promise.

First contact

import pandas as pd

df = pd.read_csv('customers.csv')
print(df.shape)
print(df.dtypes)
(3138, 11)
customer_id object
signup_date object
tenure_months int64
contract object
internet_service object
payment_method object
has_dependents object
support_calls int64
monthly_charges float64
total_charges object
churned int64
dtype: object

Two things are already wrong. signup_date is text, not a date, expected, since CSV has no date type. But total_charges is text too, and that is a defect: it should be a number.

Why a numeric column arrives as text

import pandas as pd
df = pd.read_csv('customers.csv')

numeric = pd.to_numeric(df['total_charges'], errors='coerce')
bad = df.loc[numeric.isna(), ['customer_id', 'tenure_months', 'total_charges']]
print('unconvertible rows:', len(bad))
print(bad.head())
unconvertible rows: 15
customer_id tenure_months total_charges
220 C00175 1
531 C02127 1
624 C02779 1
749 C02411 1
870 C00436 1

Fifteen rows, every one of them a customer whose tenure is one month. They have not been billed yet, so the field holds a single space, and one non-numeric value among three thousand numbers forces the whole column to text. errors='coerce' turns what cannot be parsed into NaN instead of raising, which is what you want here.

Do not fill those with zero

Zero means “billed nothing”. These customers mean “not billed yet”. Writing zero invents a fact and drags the column mean down. Leave them missing and decide deliberately in week 8: missingness that carries meaning is a feature, not a nuisance.

Categories that are not as tidy as they look

import pandas as pd
df = pd.read_csv('customers.csv')

print('distinct contract values:', df['contract'].nunique())
print(df['contract'].value_counts())
distinct contract values: 15
contract
Month-to-month 1611
One year 710
Two year 592
Month-to-month 43
MONTH-TO-MONTH 31
Month-to-month 25
month-to-month 22
TWO YEAR 18
One year 18
One year 17
ONE YEAR 14
Two year 12
Two year 10
two year 8
one year 7
Name: count, dtype: int64

Fifteen levels of a three-level variable. Group by this column and you get fifteen groups; one-hot encode it and you get fifteen columns, twelve of which are near-empty noise. Normalising is two calls:

import pandas as pd
df = pd.read_csv('customers.csv')

df['contract'] = df['contract'].str.strip().str.title()
print(df['contract'].value_counts())
contract
Month-To-Month 1732
One Year 766
Two Year 640
Name: count, dtype: int64

.str.strip() removes the padding, .str.title() puts casing back in one form. Three levels, as intended.

Duplicates

import pandas as pd
df = pd.read_csv('customers.csv')

print('fully duplicated rows:', df.duplicated().sum())
print('repeated customer ids:', df['customer_id'].duplicated().sum())

clean = df.drop_duplicates()
print('rows before', len(df), '-> after', len(clean))
fully duplicated rows: 138
repeated customer ids: 138
rows before 3138 -> after 3000

Duplicates leak across a train/test split

138 rows appear twice. Split the data afterwards and some customers land in both halves. The model then gets tested on rows it trained on, and the test score reports memorisation as skill. De-duplicate before you split, never after.

Missing values

import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')

missing = df.isna().sum()
print(missing[missing > 0])
print('\nrows with at least one gap:', df.isna().any(axis=1).sum())
has_dependents 180
monthly_charges 165
total_charges 15
dtype: int64

rows with at least one gap: 346

346 rows of 3000. Dropping them costs you eleven percent of the data and, worse, assumes the gaps are random. If customers who decline to state dependents churn differently, dropping them biases the model. Week 8 handles this properly with imputation inside a pipeline.

The target

import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates()

print(df['churned'].value_counts())
print('churn rate: %.1f%%' % (df['churned'].mean() * 100))
churned
0 2196
1 804
Name: count, dtype: int64
churn rate: 26.8%

Write that number down

26.8 percent churn means a model that predicts “nobody churns” and never gets anything right is 73.2 percent accurate. That is your floor. Any accuracy you report from now on has to be compared against it, and week 7 will show you why accuracy is the wrong headline metric here at all.

Day 4 takeaway

Check dtypes first: a numeric column arriving as object means a non-numeric value is hiding in it. Normalise category text before counting or encoding. De-duplicate before splitting, not after. Count missing values but do not fill them yet. And always compute the majority-class rate, because that is the score your model has to beat.
Week 01 · Day 5 of 7

Seeing the Data Before Modelling It

matplotlib, seaborn, and the charts that actually decide things

By 764 words

Summary statistics hide shape. Two columns with the same mean and standard deviation can look nothing alike, and the difference decides which model will work. Plot before you model.

matplotlib, and the one thing to learn about it

matplotlib has two interfaces. The plt.plot() style keeps hidden state about which figure is current and breaks the moment you draw two things. Use the explicit style: ask for a figure and axes, then draw on the axes.

import matplotlib
matplotlib.use('Agg') # no window needed; write straight to a file
import matplotlib.pyplot as plt
import pandas as pd

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

fig, ax = plt.subplots(figsize=(7, 4))
ax.hist(df['tenure_months'], bins=36, color='#2563eb', edgecolor='white')
ax.set_xlabel('Tenure (months)')
ax.set_ylabel('Customers')
ax.set_title('How long customers have been with us')
fig.tight_layout()
fig.savefig('tenure.png', dpi=120)
plt.close(fig)
print('written')
written

Agg means headless

matplotlib.use('Agg') selects a backend that renders to a file with no display attached. In a notebook you do not need it. In a script, on a server, or in a scheduled job you do. Without it, matplotlib tries to open a window and the job hangs or crashes.

Comparing a distribution across groups

The question that matters is never “what does tenure look like”, it is “does tenure look different for the people who left”. Overlay the two.

import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import pandas as pd

df = pd.read_csv('customers.csv').drop_duplicates()
stayed = df.loc[df['churned'] == 0, 'tenure_months']
left = df.loc[df['churned'] == 1, 'tenure_months']

fig, ax = plt.subplots(figsize=(7, 4))
ax.hist([stayed, left], bins=24, density=True,
label=['Stayed', 'Churned'], color=['#2563eb', '#dc2626'])
ax.set_xlabel('Tenure (months)')
ax.set_ylabel('Share of group')
ax.legend()
fig.tight_layout()
fig.savefig('tenure_by_churn.png', dpi=120)
plt.close(fig)

print(df.groupby('churned')['tenure_months'].describe().round(1))
count mean std min 25% 50% 75% max
churned
0 2196.0 26.9 18.7 1.0 13.0 22.0 37.0 72.0
1 804.0 14.1 9.1 1.0 8.0 12.0 18.0 72.0

density=True, not counts

2196 customers stayed and 804 left. Plot raw counts and the churned bars are dwarfed regardless of shape. density=True scales each group to its own area, so you compare shapes rather than group sizes. Getting this wrong is the most common misleading chart in churn analysis.

The medians are 22 months against 12. That gap is the single strongest signal in the dataset, and any model that fails to find it is broken.

seaborn for the statistical plots

seaborn sits on matplotlib and knows about DataFrames, so the plots you want during exploration are one line each.

import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns

df = pd.read_csv('customers.csv').drop_duplicates()
df['contract'] = df['contract'].str.strip().str.title()

fig, ax = plt.subplots(figsize=(7, 4))
sns.barplot(data=df, x='contract', y='churned', errorbar=('ci', 95), ax=ax)
ax.set_ylabel('Churn rate')
ax.set_xlabel('')
fig.tight_layout()
fig.savefig('churn_by_contract.png', dpi=120)
plt.close(fig)

print(df.groupby('contract')['churned'].agg(['size', 'mean']).round(3))
size mean
contract
Month-To-Month 1660 0.414
One Year 731 0.137
Two Year 609 0.028

Because churned is 0 or 1, its mean is the churn rate, so a bar chart of the mean is a bar chart of the rate. 41 percent against under 3 percent: contract type matters enormously.

Correlation, and what it will not tell you

import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')

num = ['tenure_months', 'monthly_charges', 'total_charges',
'support_calls', 'churned']
print(df[num].corr().round(2))
tenure_months monthly_charges total_charges support_calls churned
tenure_months 1.00 0.00 0.83 0.03 -0.32
monthly_charges 0.00 1.00 0.45 0.15 0.21
total_charges 0.83 0.45 1.00 0.10 -0.21
support_calls 0.03 0.15 0.10 1.00 0.08
churned -0.32 0.21 -0.21 0.08 1.00

0.83 between tenure and total charges

Of course: total charges is roughly monthly charges multiplied by tenure. The column is nearly a restatement of two others. Correlations that high between features cause unstable coefficients in linear models, a problem called multicollinearity that week 4 addresses. Note it now; do not delete anything yet.

Also note that correlation only sees straight-line relationships between numbers. It says nothing about contract, which the bar chart just showed is the strongest predictor you have. A correlation matrix is a starting point, never a feature-selection method.

Day 5 takeaway

Use the explicit fig, ax interface and Agg outside notebooks. Compare distributions with density=True so unequal group sizes do not mislead. The mean of a 0/1 column is a rate. Correlation catches linear relationships between numeric columns only, and will miss your best categorical predictor entirely.
Week 01 · Day 6 of 7

The scikit-learn Contract

fit, predict, transform, and the rule about fitting on test data

By 654 words

scikit‑learn is large, but it is not complicated, because nearly every object in it obeys the same three-method contract. Learn the contract once and you can use a model you have never heard of.

The contract

MethodWho has itWhat it does
fit(X, y)EverythingLearn from data. Returns the object itself.
predict(X)ModelsProduce an answer for new rows.
transform(X)PreprocessorsProduce a modified version of the input.
fit_transform(X)PreprocessorsBoth, in one call. Training data only.
predict_proba(X)Most classifiersClass probabilities rather than a hard label.
Estimator: Any scikit-learn object with a fit method. A model is an estimator that also predicts; a scaler is an estimator that also transforms. This is why swapping a random forest for a logistic regression is a one-line change.

The contract in action

from sklearn.linear_model import LinearRegression
from sklearn.ensemble import RandomForestRegressor
from sklearn.datasets import make_regression

X, y = make_regression(n_samples=200, n_features=3, noise=12, random_state=0)

# Identical calls, entirely different algorithms.
for model in [LinearRegression(), RandomForestRegressor(random_state=0)]:
model.fit(X, y)
pred = model.predict(X[:3])
print(f'{type(model).__name__:24s} {pred.round(1)}')
LinearRegression [ 21.3 -94.4 204.5]
RandomForestRegressor [ 17.4 -87.2 195.7]

Learned state lives on the object, with a trailing underscore

A convention worth knowing: attributes that exist only after fit end in an underscore. If you see coef_ or n_features_in_, that value was learned from data. Anything without the underscore is a setting you chose.

from sklearn.linear_model import LinearRegression
from sklearn.datasets import make_regression

X, y = make_regression(n_samples=200, n_features=3, noise=12, random_state=0)
model = LinearRegression()

print('before fit:', [a for a in dir(model) if a.endswith('_')
and not a.startswith('_')])
model.fit(X, y)
print('after fit: coef_ =', model.coef_.round(2))
print(' intercept_ = %.2f' % model.intercept_)
print(' n_features_in_ =', model.n_features_in_)
before fit: []
after fit: coef_ = [25.31 60.45 57.45]
intercept_ = -0.33
n_features_in_ = 3
Hyperparameter: A setting you choose before fitting, the depth of a tree, the strength of a penalty, the number of neighbours. Distinct from a parameter, which the algorithm learns. Hyperparameters are passed to the constructor; parameters appear afterwards with a trailing underscore. Week 14 is about choosing them well.

Transformers, and the rule about fitting them

A scaler learns something from the data, the mean and standard deviation of each column, and that makes it dangerous. It must learn from training data only.

import numpy as np
from sklearn.preprocessing import StandardScaler

train = np.array([[10.0], [20.0], [30.0], [40.0]])
test = np.array([[50.0], [60.0]])

scaler = StandardScaler()
train_scaled = scaler.fit_transform(train) # learn AND apply
test_scaled = scaler.transform(test) # apply only

print('learned mean %.1f, scale %.2f' % (scaler.mean_[0], scaler.scale_[0]))
print('train:', train_scaled.ravel().round(2))
print('test: ', test_scaled.ravel().round(2))
learned mean 25.0, scale 11.18
train: [-1.34 -0.45 0.45 1.34]
test: [2.24 3.13]

fit_transform on test data is a leak

Call fit_transform on the test set and the scaler learns the test set's mean. Information about data the model is not supposed to have seen flows into the features, the test score improves, and it is a lie. fit_transform on train, transform on test, every time. Week 8's pipelines make it impossible to get backwards, which is exactly why they exist.

Reading the documentation

Every estimator's docstring lists its hyperparameters with defaults and its learned attributes. You do not need to memorise them:

from sklearn.ensemble import RandomForestClassifier

params = RandomForestClassifier().get_params()
for k in ['n_estimators', 'max_depth', 'min_samples_leaf', 'random_state']:
print(f'{k:20s} {params[k]}')
n_estimators 100
max_depth None
min_samples_leaf 1
random_state None

Set random_state on everything

random_state=None means results change between runs. For anything you intend to compare, report or debug, pass an explicit integer. Two models whose scores differ by 0.4 percent are indistinguishable if you never fixed the seed.

Day 6 takeaway

Every scikit-learn object fits, and then either predicts or transforms. Settings go into the constructor; learned values come back with a trailing underscore. Fit transformers on training data and only transform the test set. Set random_state wherever it is offered.
Week 01 · Day 7 of 7

Your First End-to-End Model

Clean, split, pipeline, fit, and measure against an honest baseline

By 1130 words

Everything so far, assembled. Today you build a working churn model, measure it honestly, and see why the obvious metric misleads.

Step 1: load and clean

import pandas as pd

df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')

print(df.shape, '| churn rate %.3f' % df['churned'].mean())
(3000, 11) | churn rate 0.268

Step 2: split before anything else

The split comes first. Any decision you make after looking at the test set, which columns to keep, how to fill gaps, which model to use, contaminates it.

import pandas as pd
from sklearn.model_selection import train_test_split

df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')

num_cols = ['tenure_months', 'support_calls', 'monthly_charges', 'total_charges']
cat_cols = ['contract', 'internet_service', 'payment_method', 'has_dependents']

X = df[num_cols + cat_cols]
y = df['churned']

X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=42)

print('train', X_train.shape, 'churn %.3f' % y_train.mean())
print('test ', X_test.shape, 'churn %.3f' % y_test.mean())
train (2250, 8) churn 0.268
test (750, 8) churn 0.268

stratify=y

Without it, a random split can hand you a test set with a noticeably different churn rate than the training set, and the score you get reflects that accident rather than the model. stratify keeps the class proportions identical in both halves. Always pass it for classification.

Step 3: one object that preprocesses and predicts

Numeric columns need their gaps filled and their scales evened out. Categorical columns need turning into numbers. A ColumnTransformer applies different treatment to different columns; a Pipeline chains that to the model so the whole thing behaves like a single estimator.

import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LogisticRegression

df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
num_cols = ['tenure_months', 'support_calls', 'monthly_charges', 'total_charges']
cat_cols = ['contract', 'internet_service', 'payment_method', 'has_dependents']
X, y = df[num_cols + cat_cols], df['churned']
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=42)

numeric = Pipeline([
('impute', SimpleImputer(strategy='median')),
('scale', StandardScaler()),
])
categorical = Pipeline([
('impute', SimpleImputer(strategy='most_frequent')),
('encode', OneHotEncoder(handle_unknown='ignore')),
])

preprocess = ColumnTransformer([
('num', numeric, num_cols),
('cat', categorical, cat_cols),
])

model = Pipeline([
('prep', preprocess),
('clf', LogisticRegression(max_iter=1000, random_state=42)),
])

model.fit(X_train, y_train)
print('fitted: %.3f' % model.score(X_test, y_test))
fitted: 0.780

Twelve lines of setup and one fit. Everything inside learns from the training data only, because the pipeline passes the split through in the right order for you.

Step 4: measure it against the floor

import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.dummy import DummyClassifier
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, roc_auc_score, confusion_matrix

df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
num_cols = ['tenure_months', 'support_calls', 'monthly_charges', 'total_charges']
cat_cols = ['contract', 'internet_service', 'payment_method', 'has_dependents']
X, y = df[num_cols + cat_cols], df['churned']
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=42)

preprocess = ColumnTransformer([
('num', Pipeline([('i', SimpleImputer(strategy='median')),
('s', StandardScaler())]), num_cols),
('cat', Pipeline([('i', SimpleImputer(strategy='most_frequent')),
('e', OneHotEncoder(handle_unknown='ignore'))]), cat_cols),
])
model = Pipeline([('prep', preprocess),
('clf', LogisticRegression(max_iter=1000, random_state=42))])
model.fit(X_train, y_train)

baseline = DummyClassifier(strategy='most_frequent').fit(X_train, y_train)

pred = model.predict(X_test)
proba = model.predict_proba(X_test)[:, 1]

print('baseline accuracy %.3f' % accuracy_score(y_test, baseline.predict(X_test)))
print('model accuracy %.3f' % accuracy_score(y_test, pred))
print('model ROC AUC %.3f' % roc_auc_score(y_test, proba))
print('\nconfusion matrix (rows = truth, cols = prediction)')
print(confusion_matrix(y_test, pred))
baseline accuracy 0.732
model accuracy 0.780
model ROC AUC 0.818

confusion matrix (rows = truth, cols = prediction)
[[499 50]
[115 86]]

78 percent sounds good until you read the matrix

Predicting “nobody churns” scores 73.2 percent. Our model scores 78.0, just under five points of real improvement, not the triumph the raw number suggests. And the matrix shows where it goes wrong: of 201 customers who actually churned, it caught 86 and missed 115. It finds fewer than half of the people you most wanted to find.

This is not a broken model. An AUC of 0.818 means it ranks customers by risk genuinely well. It is a badly chosen threshold. The model outputs a probability, and something has to decide where to cut. The default cut is 0.5, which is almost never the right choice when one class is rarer than the other. Week 7 is about fixing exactly this.

Step 5: what did it learn

import pandas as pd, numpy as np
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LogisticRegression

df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
num_cols = ['tenure_months', 'support_calls', 'monthly_charges', 'total_charges']
cat_cols = ['contract', 'internet_service', 'payment_method', 'has_dependents']
X, y = df[num_cols + cat_cols], df['churned']
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=42)
preprocess = ColumnTransformer([
('num', Pipeline([('i', SimpleImputer(strategy='median')),
('s', StandardScaler())]), num_cols),
('cat', Pipeline([('i', SimpleImputer(strategy='most_frequent')),
('e', OneHotEncoder(handle_unknown='ignore'))]), cat_cols),
])
model = Pipeline([('prep', preprocess),
('clf', LogisticRegression(max_iter=1000, random_state=42))])
model.fit(X_train, y_train)

names = model.named_steps['prep'].get_feature_names_out()
coefs = model.named_steps['clf'].coef_[0]
effect = pd.Series(coefs, index=names).sort_values()

print('pushes towards staying:')
print(effect.head(3).round(2))
print('\npushes towards churning:')
print(effect.tail(3).round(2))
pushes towards staying:
cat__contract_Two Year -1.44
num__tenure_months -0.81
cat__payment_method_Credit card -0.20
dtype: float64

pushes towards churning:
cat__payment_method_Electronic check 0.46
num__monthly_charges 0.48
cat__contract_Month-To-Month 1.31
dtype: float64

Compare that against the generator you wrote on day 3: month-to-month contracts push churn up, two-year contracts and long tenure push it down, electronic-check payment pushes it up. The model recovered the rule that created the data. That is what a good fit looks like.

Your assignment

Swap LogisticRegression for RandomForestClassifier(n_estimators=300, random_state=42), a one-line change, because both obey the same contract. Record accuracy and AUC. You will find the forest scores worse on AUC. Write down why you think that is; week 6 gives the answer, and it is not that you did something wrong.

Day 7 takeaway

The end-to-end shape is: clean, split, build a pipeline that preprocesses and predicts, fit on train, score on test, compare against a baseline. You have a churn model beating the majority-class floor by just under five points with an AUC of 0.818, and a confusion matrix showing it misses most churners, because the default 0.5 threshold is wrong for imbalanced data.