Skip to the content
On this page
  1. Two cross-course checks run as part of the suite
  2. The three datasets
  3. Experiment 1 — Import and export data with pandas
  4. Experiment 2 — Data pre-processing techniques
  5. Experiment 3 — Dimensionality reduction with PCA
  6. Experiment 4 — Data visualization techniques
  7. Experiment 5 — Maximum likelihood estimation
  8. Experiment 6 — Simple and multiple linear regression
  9. Experiment 7 — Logistic regression
  10. Experiment 8 — Decision tree classification
  11. Experiment 9 — Naive Bayes classification
  12. Experiment 10 — K-Means clustering
  13. Experiment 11 — k-Nearest Neighbour classification
  14. Experiment 12 — DBSCAN
  15. Lab examination
  16. Each program, on its own page

12 experiments, each set out as 1. Question, 2. Aim, 3. Steps, 4. Programme, 5. Execution and Results.

Code lives in labs/course-12a-ml/.

NOTE

Everything in this course runs. scikit-learn, NumPy, pandas, SciPy and matplotlib are all installed, so all 12 practicals execute and every figure in these notes is asserted by tools/data-science/run_ml_labs.py. Under 5. Execution and Results is what each one printed, on the versions in output/VERSIONS.txt; Experiment 4's charts are shown as it drew them.

There is no "NOT EXECUTED" file anywhere in Machine Learning. Nothing this course needs is blocked by the verification environment.

Eleven courses run every experiment: Problem Solving Using C, Python Programming and Data Structures, Data Science with R, Web Technologies, Data Mining, Python for Data Analysis and Visualization, Document Oriented Database, Machine Learning, Big Data Technologies, Artificial Intelligence and Time Series Analysis and Forecasting. (Updated October 2026: this said five; R, MongoDB and WEKA became installable here, and the Tkinter programs run under a virtual display. Then nine; the Hadoop stack and SWI-Prolog became installable too.)

Every script fixes random_state, so the numbers below reproduce on your machine. If you get something different, something differs — that is the point of fixing the seed.

pip install -r tools/requirements.txt
python3 tools/data-science/run_ml_labs.py

Two cross-course checks run as part of the suite

Check Asserts
Experiment 6 against Statistical Foundations for Data Science scikit-learn reproduces Statistical Foundations for Data Science's hand-computed regression — slope 4.3030, intercept 43.0303, R² 0.9958, prediction at 7.5 = 75.3030
Experiments 8 and 9 against Data Mining The same entropy and information gain (0.2467) and the same Naive Bayes posteriors (0.005291 and 0.020571)

If the two courses ever disagree, one of them is wrong, and the suite says so. That is the point of reusing the datasets rather than inventing new ones.

The three datasets

Name What Why
STUDY Statistical Foundations for Data Science's ten (hours, score) pairs Cross-course verification, and small enough to check by hand
iris 150 flowers, 4 features, 3 species Small, clean, famous — and the same data Data Mining used in WEKA
churn 400 customers, 15% positive Built, not loaded, so the base rate is exactly known — which is what makes Unit 2's accuracy argument measurable

Experiment 1 — Import and export data with pandas

1. Question

Import and export data in CSV, Excel, JSON and Parquet with pandas.

2. Aim

Round-trip a frame through each format, and catch what each one silently changes.

3. Steps

  1. Write a CSV and read it back.
  2. See what the default index does.
  3. See which types a CSV loses.
  4. Write and read Excel, with several sheets.
  5. Compare the JSON orientations.
  6. Round-trip through Parquet.
  7. Read part of a large file.

THE TRAPS

Every format has a way of silently changing your data, and each one is demonstrated failing and then fixed:

Trap What happens
to_csv() with the default index Comes back as a junk Unnamed: 0 column
CSV stores text "001", "002", "010" returns as [1, 2, 10] — leading zeros gone
Dates through CSV Come back as strings, not dates
JSON orient Five orientations produce five different files; read with the wrong one and the frame is transposed or empty

Fixes asserted: index=False, dtype={"id": str}, parse_dates=[...], and Parquet, which preserves every dtype exactly. Also nrows, usecols and chunksize for files that will not fit in memory.

4. Programme

"""Experiment 1 — Import and export data with pandas.

The first practical, and the one that decides whether the rest of the course is
frustrating. Every format has a way of silently changing your data on the round
trip, and this script demonstrates each one failing and then fixing it.
"""
import io
import json
import pathlib
import tempfile

import pandas as pd

from fixtures import STUDY, churn

TMP = pathlib.Path(tempfile.mkdtemp(prefix="ml_lab1_"))


def csv_round_trip():
    path = TMP / "study.csv"
    STUDY.to_csv(path, index=False)
    back = pd.read_csv(path)

    assert back.shape == STUDY.shape == (10, 2)
    assert list(back.columns) == ["hours", "score"]
    assert back["score"].sum() == STUDY["score"].sum() == 710
    assert back.equals(STUDY)

    print(f"  CSV round trip: {back.shape}, score total {back['score'].sum()} -- identical")


def index_false_matters():
    """to_csv(index=True) adds a phantom column that read_csv turns into data."""
    with_index = TMP / "with_index.csv"
    STUDY.to_csv(with_index)                      # index=True is the DEFAULT
    back = pd.read_csv(with_index)

    assert back.shape == (10, 3), back.shape
    assert back.columns[0] == "Unnamed: 0", list(back.columns)

    fixed = pd.read_csv(with_index, index_col=0)
    assert fixed.shape == (10, 2)
    assert fixed.equals(STUDY)

    print(f"  to_csv() with the default index -> read back as {back.shape}, "
          f"first column '{back.columns[0]}'")
    print("       'Unnamed: 0' is the commonest junk column in data science.")
    print("       Write with index=False, or read with index_col=0")


def dtypes_are_not_preserved_by_csv():
    """CSV is text. Types are GUESSED on the way back in."""
    df = pd.DataFrame({"id": ["001", "002", "010"],
                       "flag": [True, False, True],
                       "when": pd.to_datetime(["2026-01-15", "2026-02-10", "2026-04-05"])})
    path = TMP / "types.csv"
    df.to_csv(path, index=False)
    back = pd.read_csv(path)

    # The zero-padded id became an integer and lost its padding.
    assert str(df["id"].dtype) == "str" or df["id"].dtype == object
    assert back["id"].tolist() == [1, 2, 10], back["id"].tolist()
    assert back["id"].tolist() != df["id"].tolist()

    # The date became a plain string. (In pandas 3 the string dtype prints as
    # "str" rather than the old "object" -- assert the property, not the name.)
    assert not pd.api.types.is_datetime64_any_dtype(back["when"])
    assert isinstance(back["when"].iloc[0], str), back["when"].iloc[0]

    fixed = pd.read_csv(path, dtype={"id": str}, parse_dates=["when"])
    assert fixed["id"].tolist() == ["001", "002", "010"]
    assert pd.api.types.is_datetime64_any_dtype(fixed["when"])

    print(f"  zero-padded id '001','002','010' -> read back as {back['id'].tolist()}")
    print(f"  dates -> dtype {back['when'].dtype} (text, not a date)")
    print("  with dtype={'id': str}, parse_dates=['when'] -> both correct")
    print("       CSV stores TEXT. Every type is re-guessed on read, and a")
    print("       pin code, phone number or account id loses its leading zeros")


def excel_and_multiple_sheets():
    path = TMP / "data.xlsx"
    with pd.ExcelWriter(path) as xl:
        STUDY.to_excel(xl, sheet_name="study", index=False)
        churn().head(20).to_excel(xl, sheet_name="churn", index=False)

    sheets = pd.read_excel(path, sheet_name=None)     # None -> a dict of ALL
    assert set(sheets) == {"study", "churn"}
    assert sheets["study"].shape == (10, 2)
    assert sheets["churn"].shape == (20, 4)

    one = pd.read_excel(path, sheet_name="study")
    assert one.equals(STUDY)

    print(f"  Excel: {len(sheets)} sheets -> {[f'{k} {v.shape}' for k, v in sheets.items()]}")
    print("       sheet_name=None returns a DICT of every sheet. Unlike CSV,")
    print("       Excel preserves dtypes, which is its one real advantage")


def json_orientations():
    """The 'orient' argument changes the file completely."""
    small = STUDY.head(3)
    shapes = {}
    for orient in ("records", "columns", "index", "split", "values"):
        text = small.to_json(orient=orient)
        shapes[orient] = len(text)
        if orient in ("records", "columns", "index", "split"):
            back = pd.read_json(io.StringIO(text), orient=orient)
            assert back.shape == small.shape, (orient, back.shape)

    assert json.loads(small.to_json(orient="records"))[0] == {"hours": 2, "score": 52}
    assert list(json.loads(small.to_json(orient="columns"))) == ["hours", "score"]

    print("  the same 3 rows, five JSON orientations:")
    for orient, size in shapes.items():
        print(f"    orient='{orient}':{'':<{10 - len(orient)}} {size} chars")
    print("       'records' is the one APIs use -- a list of objects. You must")
    print("       pass the SAME orient to read_json, or the frame comes back")
    print("       transposed or empty")


def parquet_preserves_everything():
    """The format to use between Python programs."""
    df = pd.DataFrame({"id": ["001", "002"],
                       "when": pd.to_datetime(["2026-01-15", "2026-02-10"]),
                       "flag": [True, False],
                       "value": [1.5, 2.5]})
    path = TMP / "data.parquet"
    try:
        df.to_parquet(path, index=False)
    except (ImportError, ValueError) as exc:
        print(f"  parquet unavailable ({type(exc).__name__}) -- skipped")
        return

    back = pd.read_parquet(path)
    assert back["id"].tolist() == ["001", "002"], "leading zeros SURVIVE"
    assert pd.api.types.is_datetime64_any_dtype(back["when"]), "dates survive"
    assert back["flag"].dtype == bool
    assert back.equals(df)

    csv_size = len(df.to_csv(index=False).encode())
    parquet_size = path.stat().st_size
    print(f"  Parquet: every dtype preserved, frame identical on round trip")
    print(f"    csv {csv_size} bytes, parquet {parquet_size} bytes "
          f"(parquet wins only on LARGE data -- it has a header)")
    print("       columnar, typed, compressed. Use it between programs;")
    print("       use CSV only when a human or a foreign tool must read it")


def reading_a_subset_of_a_large_file():
    """usecols and nrows -- what you do when the file will not fit."""
    big = churn(n=400)
    path = TMP / "big.csv"
    big.to_csv(path, index=False)

    head = pd.read_csv(path, nrows=5)
    assert head.shape == (5, 4)

    two_cols = pd.read_csv(path, usecols=["tenure_months", "churned"])
    assert two_cols.shape == (400, 2), two_cols.shape

    chunks = list(pd.read_csv(path, chunksize=150))
    assert [len(c) for c in chunks] == [150, 150, 100]
    assert sum(len(c) for c in chunks) == 400

    print(f"  nrows=5              -> {head.shape}")
    print(f"  usecols=[2 columns]  -> {two_cols.shape}")
    print(f"  chunksize=150        -> {[len(c) for c in chunks]} rows per chunk")
    print("       read the head FIRST to see the columns, then usecols to load")
    print("       only what you need. chunksize when it will not fit in memory")


def main():
    print("Experiment 1 -- Importing and exporting data with pandas")
    # Step 1: Write a CSV and read it back
    csv_round_trip()
    # Step 2: See what the default index does
    index_false_matters()
    # Step 3: See which types a CSV loses
    dtypes_are_not_preserved_by_csv()
    # Step 4: Write and read Excel, with several sheets
    excel_and_multiple_sheets()
    # Step 5: Compare the JSON orientations
    json_orientations()
    # Step 6: Round-trip through Parquet
    parquet_preserves_everything()
    # Step 7: Read part of a large file
    reading_a_subset_of_a_large_file()


if __name__ == "__main__":
    main()

5. Execution and Results

OUTPUT

Experiment 1 -- Importing and exporting data with pandas
  CSV round trip: (10, 2), score total 710 -- identical
  to_csv() with the default index -> read back as (10, 3), first column 'Unnamed: 0'
       'Unnamed: 0' is the commonest junk column in data science.
       Write with index=False, or read with index_col=0
  zero-padded id '001','002','010' -> read back as [1, 2, 10]
  dates -> dtype str (text, not a date)
  with dtype={'id': str}, parse_dates=['when'] -> both correct
       CSV stores TEXT. Every type is re-guessed on read, and a
       pin code, phone number or account id loses its leading zeros
  Excel: 2 sheets -> ['study (10, 2)', 'churn (20, 4)']
       sheet_name=None returns a DICT of every sheet. Unlike CSV,
       Excel preserves dtypes, which is its one real advantage
  the same 3 rows, five JSON orientations:
    orient='records':    70 chars
    orient='columns':    60 chars
    orient='index':      82 chars
    orient='split':      75 chars
    orient='values':     22 chars
       'records' is the one APIs use -- a list of objects. You must
       pass the SAME orient to read_json, or the frame comes back
       transposed or empty
  Parquet: every dtype preserved, frame identical on round trip
    csv 68 bytes, parquet 2661 bytes (parquet wins only on LARGE data -- it has a header)
       columnar, typed, compressed. Use it between programs;
       use CSV only when a human or a foreign tool must read it
  nrows=5              -> (5, 4)
  usecols=[2 columns]  -> (400, 2)
  chunksize=150        -> [150, 150, 100] rows per chunk
       read the head FIRST to see the columns, then usecols to load
       only what you need. chunksize when it will not fit in memory

RESULT

Every trap is shown failing and then fixed; Parquet alone returns every dtype unchanged.

Experiment 2 — Data pre-processing techniques

1. Question

Apply data pre-processing techniques: scaling, encoding and imputation.

2. Aim

Scale, encode and impute, and measure what each choice does to the data and to a model.

3. Steps

  1. Compare the scalers, with an outlier.
  2. Encode the categories.
  3. Meet the dummy variable trap.
  4. See mean imputation shrink the variance.
  5. Keep a was-missing flag.
  6. Scale after splitting, in a pipeline.

THE RESULTS

Result Figure
Min-max vs robust scaling with one outlier Min-max squeezes six normal points into a 0.0125-wide band; robust keeps them 160× further apart
Mean imputation shrinks variance 30% missing: mean preserved exactly, standard deviation falls 16.3%
A was_missing flag when missingness is informative CV accuracy 0.4550 → 0.7575, a gain of +0.3025 from one binary column
Label-encoding a nominal feature Invents distances — Red is "nearer" Green than Blue, which is a fact about alphabetical order
The dummy variable trap Every full one-hot row sums to 1, so it is collinear with the intercept

4. Programme

"""Experiment 2 — Data pre-processing techniques.

Unit 2 section 2.1 claims the ORDER of these steps matters and that getting it
wrong corrupts your evaluation silently. This script measures that, rather than
asserting it in prose.
"""
import numpy as np
import pandas as pd
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score, train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import (MinMaxScaler, OneHotEncoder, OrdinalEncoder,
                                   RobustScaler, StandardScaler)

from fixtures import RANDOM_STATE, churn


def scaling_compared():
    """Standard, min-max and robust, on data with one deliberate outlier."""
    x = np.array([10.0, 12.0, 11.0, 13.0, 12.0, 11.0, 250.0]).reshape(-1, 1)

    std = StandardScaler().fit_transform(x).ravel()
    mm = MinMaxScaler().fit_transform(x).ravel()
    rob = RobustScaler().fit_transform(x).ravel()

    assert round(std.mean(), 10) == 0.0
    assert round(std.std(), 10) == 1.0
    assert round(mm.min(), 10) == 0.0 and round(mm.max(), 10) == 1.0

    # The outlier crushes min-max: the six normal points land in a tiny band.
    normal_spread_mm = mm[:6].max() - mm[:6].min()
    normal_spread_rob = rob[:6].max() - rob[:6].min()
    # min-max: the six normal points span 3 of a total range of 240 -> 0.0125.
    # robust: they span 3 divided by the IQR of 1.5 -> 2.0. A factor of 160.
    assert round(normal_spread_mm, 4) == 0.0125, round(normal_spread_mm, 4)
    assert round(normal_spread_rob, 4) == 2.0, round(normal_spread_rob, 4)
    assert round(normal_spread_rob / normal_spread_mm, 6) == 160.0

    print("  x = [10, 12, 11, 13, 12, 11, 250]   (one outlier)")
    print(f"    {'standardised':16s} {np.round(std, 3)}")
    print(f"    {'min-max':16s} {np.round(mm, 4)}")
    print(f"    {'robust':16s} {np.round(rob, 3)}")
    print(f"  spread of the six NORMAL points after scaling:")
    print(f"    min-max {normal_spread_mm:.4f}   robust {normal_spread_rob:.4f}")
    print(f"    robust keeps them {normal_spread_rob / normal_spread_mm:.0f}x further apart")
    print("       min-max squeezed six distinct values into a 0.0125-wide band,")
    print("       because the outlier owns the whole range. Robust scaling uses")
    print("       the median and IQR, so the outlier does not set the scale")


def encoding_categoricals():
    """One-hot for nominal, ordinal for ordinal -- and why swapping them hurts."""
    colours = pd.DataFrame({"colour": ["Red", "Green", "Blue", "Red"]})
    sizes = pd.DataFrame({"size": ["S", "M", "L", "M"]})

    onehot = OneHotEncoder(sparse_output=False).fit_transform(colours)
    assert onehot.shape == (4, 3), onehot.shape
    assert onehot.sum(axis=1).tolist() == [1.0, 1.0, 1.0, 1.0], "exactly one 1 per row"

    ordinal = OrdinalEncoder(categories=[["S", "M", "L"]]).fit_transform(sizes)
    assert ordinal.ravel().tolist() == [0.0, 1.0, 2.0, 1.0]

    # The mistake: label-encoding a NOMINAL feature invents distances.
    bad = OrdinalEncoder().fit_transform(colours).ravel()
    order = dict(zip(colours["colour"], bad))
    d_red_green = abs(order["Red"] - order["Green"])
    d_red_blue = abs(order["Red"] - order["Blue"])
    assert d_red_green != d_red_blue, "the encoding has invented a distance"

    print(f"  nominal 'colour' one-hot -> shape {onehot.shape}, one 1 per row")
    print(f"  ordinal 'size' S<M<L     -> {ordinal.ravel().tolist()}  (order kept)")
    print(f"  colour LABEL-encoded     -> {bad.tolist()}")
    print(f"    distance Red-Green {d_red_green:.0f}, Red-Blue {d_red_blue:.0f}")
    print("       the encoder just told every distance-based model that Red is")
    print("       nearer one colour than another. That is not a fact about")
    print("       colours; it is a fact about alphabetical order")


def the_dummy_variable_trap():
    """k one-hot columns are perfectly collinear. Drop one for linear models."""
    colours = pd.DataFrame({"colour": ["Red", "Green", "Blue", "Red", "Green"]})

    full = OneHotEncoder(sparse_output=False).fit_transform(colours)
    dropped = OneHotEncoder(sparse_output=False, drop="first").fit_transform(colours)

    assert full.shape == (5, 3) and dropped.shape == (5, 2)
    # Every row of the full encoding sums to 1 -- perfect collinearity with the
    # intercept column a linear model adds.
    assert np.allclose(full.sum(axis=1), 1.0)
    # Dropping one column loses NO information: the dropped category is the
    # one where all remaining columns are zero.
    baseline_rows = (dropped.sum(axis=1) == 0).sum()
    assert baseline_rows == 1, baseline_rows

    print(f"  3 categories -> full one-hot {full.shape}, drop='first' {dropped.shape}")
    print(f"    every full row sums to 1.0 -> collinear with the intercept")
    print(f"    dropped encoding: {baseline_rows} row is all-zero = the baseline category")
    print("       drop one for LINEAR and LOGISTIC regression. Trees and")
    print("       regularised models do not care")


def imputation_shrinks_variance():
    """Mean imputation is not free -- it distorts the distribution."""
    rng = np.random.default_rng(RANDOM_STATE)
    full = rng.normal(50, 10, 200)
    holed = full.copy()
    holed[rng.choice(200, 60, replace=False)] = np.nan

    imputed = SimpleImputer(strategy="mean").fit_transform(
        holed.reshape(-1, 1)).ravel()

    assert np.isnan(holed).sum() == 60
    assert not np.isnan(imputed).any()
    assert round(abs(imputed.mean() - np.nanmean(holed)), 10) == 0.0, \
        "mean imputation preserves the MEAN exactly"
    assert imputed.std() < np.nanstd(holed), "but it SHRINKS the spread"

    print(f"  200 values, 60 made missing (30%):")
    print(f"    observed      mean {np.nanmean(holed):7.4f}  sd {np.nanstd(holed):7.4f}")
    print(f"    mean-imputed  mean {imputed.mean():7.4f}  sd {imputed.std():7.4f}")
    print(f"    sd fell by {(1 - imputed.std() / np.nanstd(holed)) * 100:.1f}%")
    print("       the mean is preserved exactly and the SPREAD is not. Sixty")
    print("       identical values were inserted at the centre. Every")
    print("       correlation involving this column is now weaker than it was")


def a_was_missing_flag_keeps_the_signal():
    """When missingness is informative, the flag is worth more than the value."""
    rng = np.random.default_rng(RANDOM_STATE)
    n = 400
    y = rng.integers(0, 2, n)
    # Income is missing FAR more often for class 1 -- missing-not-at-random.
    income = rng.normal(50000, 12000, n)
    missing = rng.random(n) < np.where(y == 1, 0.6, 0.1)
    income[missing] = np.nan

    imputed = pd.Series(income).fillna(np.nanmean(income)).to_numpy()
    flag = missing.astype(int)

    Xa = imputed.reshape(-1, 1)
    Xb = np.c_[imputed, flag]
    cv_a = cross_val_score(LogisticRegression(), Xa, y, cv=5).mean()
    cv_b = cross_val_score(LogisticRegression(), Xb, y, cv=5).mean()

    assert cv_b > cv_a + 0.10, (cv_a, cv_b)

    print(f"  income missing 60% of the time for class 1, 10% for class 0:")
    print(f"    imputed value only        CV accuracy {cv_a:.4f}")
    print(f"    imputed value + was_missing flag       {cv_b:.4f}")
    print(f"    the flag alone is worth {cv_b - cv_a:+.4f}")
    print("       WHY a value is missing can matter more than what it was.")
    print("       Imputing erases that; one extra column keeps it")


def scaling_before_splitting_leaks():
    """The rule from unit-2.md 2.1, measured on the churn data."""
    df = churn()
    X = df[["tenure_months", "support_calls", "irrelevant"]].to_numpy()
    y = df["churned"].to_numpy()

    # WRONG: scale the whole dataset, then split.
    X_all_scaled = StandardScaler().fit_transform(X)
    Xtr_w, Xte_w, ytr_w, yte_w = train_test_split(
        X_all_scaled, y, test_size=0.25, random_state=RANDOM_STATE, stratify=y)
    leaked = LogisticRegression(random_state=RANDOM_STATE).fit(
        Xtr_w, ytr_w).score(Xte_w, yte_w)

    # RIGHT: split first, and let a Pipeline fit the scaler on train only.
    Xtr, Xte, ytr, yte = train_test_split(
        X, y, test_size=0.25, random_state=RANDOM_STATE, stratify=y)
    clean = make_pipeline(
        StandardScaler(),
        LogisticRegression(random_state=RANDOM_STATE)).fit(Xtr, ytr).score(Xte, yte)

    # The scaler's own parameters differ, which is the mechanism of the leak.
    mean_all = StandardScaler().fit(X).mean_
    mean_train = StandardScaler().fit(Xtr).mean_
    assert not np.allclose(mean_all, mean_train), \
        "the two scalers learned DIFFERENT means -- that difference is the leak"

    print(f"  scale-then-split (leaky) test accuracy : {leaked:.4f}")
    print(f"  split-then-scale (Pipeline)            : {clean:.4f}")
    print(f"  scaler mean fitted on ALL data   : {np.round(mean_all, 4)}")
    print(f"  scaler mean fitted on TRAIN only : {np.round(mean_train, 4)}")
    print("       on this data the two scores happen to be close -- the point is")
    print("       NOT that leakage always inflates the number, but that the")
    print("       leaky score is not an estimate of anything, because the")
    print("       transformer saw the test rows. Use a Pipeline and the question")
    print("       cannot arise")


def main():
    print("Experiment 2 -- Data pre-processing techniques")
    # Step 1: Compare the scalers, with an outlier
    scaling_compared()
    # Step 2: Encode the categories
    encoding_categoricals()
    # Step 3: Meet the dummy variable trap
    the_dummy_variable_trap()
    # Step 4: See mean imputation shrink the variance
    imputation_shrinks_variance()
    # Step 5: Keep a was-missing flag
    a_was_missing_flag_keeps_the_signal()
    # Step 6: Scale after splitting, in a pipeline
    scaling_before_splitting_leaks()


if __name__ == "__main__":
    main()

5. Execution and Results

OUTPUT

Experiment 2 -- Data pre-processing techniques
  x = [10, 12, 11, 13, 12, 11, 250]   (one outlier)
    standardised     [-0.426 -0.402 -0.414 -0.39  -0.402 -0.414  2.449]
    min-max          [0.     0.0083 0.0042 0.0125 0.0083 0.0042 1.    ]
    robust           [ -1.333   0.     -0.667   0.667   0.     -0.667 158.667]
  spread of the six NORMAL points after scaling:
    min-max 0.0125   robust 2.0000
    robust keeps them 160x further apart
       min-max squeezed six distinct values into a 0.0125-wide band,
       because the outlier owns the whole range. Robust scaling uses
       the median and IQR, so the outlier does not set the scale
  nominal 'colour' one-hot -> shape (4, 3), one 1 per row
  ordinal 'size' S<M<L     -> [0.0, 1.0, 2.0, 1.0]  (order kept)
  colour LABEL-encoded     -> [2.0, 1.0, 0.0, 2.0]
    distance Red-Green 1, Red-Blue 2
       the encoder just told every distance-based model that Red is
       nearer one colour than another. That is not a fact about
       colours; it is a fact about alphabetical order
  3 categories -> full one-hot (5, 3), drop='first' (5, 2)
    every full row sums to 1.0 -> collinear with the intercept
    dropped encoding: 1 row is all-zero = the baseline category
       drop one for LINEAR and LOGISTIC regression. Trees and
       regularised models do not care
  200 values, 60 made missing (30%):
    observed      mean 49.9646  sd  8.7813
    mean-imputed  mean 49.9646  sd  7.3470
    sd fell by 16.3%
       the mean is preserved exactly and the SPREAD is not. Sixty
       identical values were inserted at the centre. Every
       correlation involving this column is now weaker than it was
  income missing 60% of the time for class 1, 10% for class 0:
    imputed value only        CV accuracy 0.4550
    imputed value + was_missing flag       0.7575
    the flag alone is worth +0.3025
       WHY a value is missing can matter more than what it was.
       Imputing erases that; one extra column keeps it
  scale-then-split (leaky) test accuracy : 0.9400
  split-then-scale (Pipeline)            : 0.9400
  scaler mean fitted on ALL data   : [ 2.14935e+01  2.59040e+00 -9.10000e-03]
  scaler mean fitted on TRAIN only : [21.263   2.6028 -0.0415]
       on this data the two scores happen to be close -- the point is
       NOT that leakage always inflates the number, but that the
       leaky score is not an estimate of anything, because the
       transformer saw the test rows. Use a Pipeline and the question
       cannot arise

THE LEAKAGE DEMONSTRATION IS DELIBERATELY HONEST

Scaling before splitting versus a Pipeline gives 0.9400 either way on this data. The script says so rather than manufacturing a gap:

NOTE

the point is not that leakage always inflates the number, but that the leaky score is not an estimate of anything, because the transformer saw the test rows.

The mechanism is asserted instead — the two scalers learn measurably different means.

RESULT

Robust scaling keeps the normal points apart where min-max crushes them; a was-missing flag lifts accuracy from 0.4550 to 0.7575.

Experiment 3 — Dimensionality reduction with PCA

1. Question

Reduce the dimensions of the iris data with Principal Component Analysis.

2. Aim

Compute the components and their explained variance, choose how many to keep, and see what PCA cannot do.

3. Steps

  1. Compute the explained variance.
  2. See why scikit-learn's variances sum to more than 4.
  3. Choose the number of components.
  4. Compare PCA with LDA.
  5. Standardise before PCA.
  6. Measure the reconstruction error.

THE COMPONENTS

Component Eigenvalue Explained Cumulative
PC1 2.9185 72.96% 72.96%
PC2 0.9140 22.85% 95.81%
PC3 0.1468 3.67% 99.48%
PC4 0.0207 0.52% 100.00%

The eigenvalues sum to exactly 4 = p, a free arithmetic check.

4. Programme

"""Experiment 3 — Dimensionality reduction with PCA.

Reproduces every figure in unit-2.md section 2.10, including the check that the
correlation-matrix eigenvalues sum to exactly p, and the disagreement between
the Kaiser criterion and the 95%-variance rule.
"""
import numpy as np
from sklearn.decomposition import PCA
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

from fixtures import RANDOM_STATE, iris_frame

_, DATA = iris_frame()
X = DATA.data.to_numpy()
y = DATA.target


def explained_variance():
    """The table in unit-2.md 2.10."""
    Z = StandardScaler().fit_transform(X)
    pca = PCA().fit(Z)
    ratio = pca.explained_variance_ratio_
    cumulative = np.cumsum(ratio)

    assert [round(v, 4) for v in ratio] == [0.7296, 0.2285, 0.0367, 0.0052]
    assert round(cumulative[1], 4) == 0.9581, round(cumulative[1], 4)
    assert round(cumulative[-1], 10) == 1.0

    # Eigenvalues of the CORRELATION matrix, which is what Kaiser refers to.
    eig = np.sort(np.linalg.eigvalsh(np.corrcoef(X, rowvar=False)))[::-1]
    assert [round(v, 4) for v in eig] == [2.9185, 0.9140, 0.1468, 0.0207]
    assert round(eig.sum(), 10) == 4.0, "eigenvalues sum to p -- a free check"
    assert np.allclose(eig / eig.sum(), ratio, atol=1e-10), \
        "the RATIOS are identical however you compute the eigenvalues"

    print("  component  eigenvalue  explained  cumulative")
    for i, (e, r, c) in enumerate(zip(eig, ratio, cumulative), 1):
        print(f"    PC{i}       {e:9.4f}   {r * 100:7.2f}%   {c * 100:8.2f}%")
    print(f"  eigenvalues sum to {eig.sum():.4f} = p = {X.shape[1]}  <- check")
    print(f"  TWO components carry {cumulative[1] * 100:.2f}% of the variance")


def sklearn_uses_n_minus_one():
    """Why sklearn's explained_variance_ differs slightly, stated precisely."""
    Z = StandardScaler().fit_transform(X)
    pca = PCA().fit(Z)
    eig_corr = np.sort(np.linalg.eigvalsh(np.corrcoef(X, rowvar=False)))[::-1]

    assert [round(v, 4) for v in pca.explained_variance_] == \
        [2.9381, 0.9202, 0.1477, 0.0209]
    assert round(pca.explained_variance_.sum(), 4) == 4.0268
    assert round(eig_corr.sum(), 4) == 4.0

    n = len(X)
    assert np.allclose(pca.explained_variance_, eig_corr * n / (n - 1), atol=1e-9), \
        "sklearn divides by n-1; StandardScaler divided by n"

    print(f"  sklearn explained_variance_ : "
          f"{[round(v, 4) for v in pca.explained_variance_]}  sum "
          f"{pca.explained_variance_.sum():.4f}")
    print(f"  correlation eigenvalues     : "
          f"{[round(v, 4) for v in eig_corr]}  sum {eig_corr.sum():.4f}")
    print(f"  the factor is exactly n/(n-1) = {n}/{n - 1} = {n / (n - 1):.6f}")
    print("       the RATIOS are identical, which is why the ratio is what you")
    print("       report and the raw eigenvalue is not")


def how_many_components():
    """Kaiser says 1, the 95% rule says 2. They disagree, and that is normal."""
    eig = np.sort(np.linalg.eigvalsh(np.corrcoef(X, rowvar=False)))[::-1]
    cumulative = np.cumsum(eig / eig.sum())

    kaiser = int((eig > 1).sum())
    rule_90 = int(np.searchsorted(cumulative, 0.90) + 1)
    rule_95 = int(np.searchsorted(cumulative, 0.95) + 1)

    assert kaiser == 1, kaiser
    assert rule_90 == 2 and rule_95 == 2

    print(f"  Kaiser (eigenvalue > 1)      -> {kaiser} component")
    print(f"  cumulative variance >= 90%   -> {rule_90} components")
    print(f"  cumulative variance >= 95%   -> {rule_95} components")
    print("       THE RULES DISAGREE, and that is normal -- Kaiser is known to")
    print("       under-select when p is small. k is a hyperparameter: state")
    print("       which rule you used, or validate it downstream")


def pca_is_unsupervised_and_it_shows():
    """PCA maximises variance, not separation. Compare with LDA."""
    from sklearn.discriminant_analysis import LinearDiscriminantAnalysis

    Z = StandardScaler().fit_transform(X)
    pcs = PCA(n_components=2).fit_transform(Z)
    lds = LinearDiscriminantAnalysis(n_components=2).fit_transform(Z, y)

    # Both give 2-D; judge them by how separable the classes become.
    pca_cv = cross_val_score(LogisticRegression(max_iter=1000), pcs, y, cv=5).mean()
    lda_cv = cross_val_score(LogisticRegression(max_iter=1000), lds, y, cv=5).mean()
    raw_cv = cross_val_score(
        make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)),
        X, y, cv=5).mean()

    assert pcs.shape == lds.shape == (150, 2)
    assert lda_cv >= pca_cv, (lda_cv, pca_cv)

    print(f"  logistic regression, 5-fold CV accuracy on 2 dimensions:")
    print(f"    PCA (unsupervised, maximises VARIANCE)   {pca_cv:.4f}")
    print(f"    LDA (supervised, maximises SEPARATION)   {lda_cv:.4f}")
    print(f"    all 4 original features                  {raw_cv:.4f}")
    print("       PCA never looks at y. When the goal is separating classes,")
    print("       LDA is the supervised alternative -- and here it matches or")
    print("       beats PCA on the same number of dimensions")


def pca_needs_standardising():
    """Without scaling, PCA reports whichever feature has the largest units."""
    # Blow up one feature's units: sepal length in micrometres.
    X_mixed = X.copy()
    X_mixed[:, 0] *= 10000

    raw = PCA().fit(X_mixed)
    scaled = PCA().fit(StandardScaler().fit_transform(X_mixed))

    # Unscaled, PC1 is essentially the inflated column alone.
    loading = abs(raw.components_[0])
    assert loading.argmax() == 0
    assert round(loading[0], 6) == 1.0, round(loading[0], 6)
    assert round(raw.explained_variance_ratio_[0], 6) == 1.0

    assert round(scaled.explained_variance_ratio_[0], 4) == 0.7296, \
        "standardised, the units cancel and the real answer returns"

    print("  sepal length rescaled to micrometres (x10,000):")
    print(f"    UNSCALED PCA: PC1 explains "
          f"{raw.explained_variance_ratio_[0] * 100:.4f}% and its loading on the")
    print(f"      inflated column is {loading[0]:.4f} -- PC1 IS that column")
    print(f"    STANDARDISED: PC1 explains "
          f"{scaled.explained_variance_ratio_[0] * 100:.2f}%, unchanged from before")
    print("       PCA maximises variance and variance has units. Without")
    print("       standardising it reports your choice of measurement scale")


def reconstruction_error():
    """What 'losing 4.19% of the information' actually means."""
    Z = StandardScaler().fit_transform(X)
    for k in (1, 2, 3, 4):
        pca = PCA(n_components=k).fit(Z)
        back = pca.inverse_transform(pca.transform(Z))
        mse = float(((Z - back) ** 2).mean())
        kept = pca.explained_variance_ratio_.sum()
        if k == 2:
            assert round(kept, 4) == 0.9581
            assert round(mse, 4) == 0.0419, round(mse, 4)
        if k == 4:
            assert round(mse, 10) == 0.0, "keeping every component loses nothing"
        print(f"    k={k}: variance kept {kept * 100:6.2f}%   "
              f"reconstruction MSE {mse:.4f}")
    print("       at k=2 the MSE is 0.0419 -- exactly 1 minus the 0.9581 kept.")
    print("       'Losing 4.19% of the variance' is literally the squared error")
    print("       you would make rebuilding the original four columns")


def main():
    print("Experiment 3 -- Principal Component Analysis")
    # Step 1: Compute the explained variance
    explained_variance()
    # Step 2: See why scikit-learn's variances sum to more than 4
    sklearn_uses_n_minus_one()
    # Step 3: Choose the number of components
    how_many_components()
    # Step 4: Compare PCA with LDA
    pca_is_unsupervised_and_it_shows()
    # Step 5: Standardise before PCA
    pca_needs_standardising()
    # Step 6: Measure the reconstruction error
    print("  reconstruction error by number of components:")
    reconstruction_error()


if __name__ == "__main__":
    main()

5. Execution and Results

OUTPUT

Experiment 3 -- Principal Component Analysis
  component  eigenvalue  explained  cumulative
    PC1          2.9185     72.96%      72.96%
    PC2          0.9140     22.85%      95.81%
    PC3          0.1468      3.67%      99.48%
    PC4          0.0207      0.52%     100.00%
  eigenvalues sum to 4.0000 = p = 4  <- check
  TWO components carry 95.81% of the variance
  sklearn explained_variance_ : [np.float64(2.9381), np.float64(0.9202), np.float64(0.1477), np.float64(0.0209)]  sum 4.0268
  correlation eigenvalues     : [np.float64(2.9185), np.float64(0.914), np.float64(0.1468), np.float64(0.0207)]  sum 4.0000
  the factor is exactly n/(n-1) = 150/149 = 1.006711
       the RATIOS are identical, which is why the ratio is what you
       report and the raw eigenvalue is not
  Kaiser (eigenvalue > 1)      -> 1 component
  cumulative variance >= 90%   -> 2 components
  cumulative variance >= 95%   -> 2 components
       THE RULES DISAGREE, and that is normal -- Kaiser is known to
       under-select when p is small. k is a hyperparameter: state
       which rule you used, or validate it downstream
  logistic regression, 5-fold CV accuracy on 2 dimensions:
    PCA (unsupervised, maximises VARIANCE)   0.9133
    LDA (supervised, maximises SEPARATION)   0.9800
    all 4 original features                  0.9600
       PCA never looks at y. When the goal is separating classes,
       LDA is the supervised alternative -- and here it matches or
       beats PCA on the same number of dimensions
  sepal length rescaled to micrometres (x10,000):
    UNSCALED PCA: PC1 explains 100.0000% and its loading on the
      inflated column is 1.0000 -- PC1 IS that column
    STANDARDISED: PC1 explains 72.96%, unchanged from before
       PCA maximises variance and variance has units. Without
       standardising it reports your choice of measurement scale
  reconstruction error by number of components:
    k=1: variance kept  72.96%   reconstruction MSE 0.2704
    k=2: variance kept  95.81%   reconstruction MSE 0.0419
    k=3: variance kept  99.48%   reconstruction MSE 0.0052
    k=4: variance kept 100.00%   reconstruction MSE 0.0000
       at k=2 the MSE is 0.0419 -- exactly 1 minus the 0.9581 kept.
       'Losing 4.19% of the variance' is literally the squared error
       you would make rebuilding the original four columns

Also asserted:

RESULT

Two components keep 95.81% of the variance; the eigenvalues sum to 4; reconstruction error equals the variance lost.

Experiment 4 — Data visualization techniques

1. Question

Apply data visualization techniques to explore a dataset.

2. Aim

Compute what each chart shows and check it, starting with Anscombe's quartet.

3. Steps

  1. Plot Anscombe's quartet.
  2. Vary the histogram's bin count.
  3. Find outliers with a boxplot.
  4. Read the scatter matrix and the correlations.
  5. Plot the class balance first.
  6. Sort x before a line plot.

THE CENTREPIECE

Anscombe's quartet, with the precision stated honestly:

set mean x mean y var y r slope intercept
I 9.00 7.50 4.13 0.816 0.500 3.00
II 9.00 7.50 4.13 0.816 0.500 3.00
III 9.00 7.50 4.12 0.816 0.500 3.00
IV 9.00 7.50 4.12 0.817 0.500 3.00

Mean x is identical, the slope agrees to three decimals, and mean y, intercept and r to two — the folklore "identical to two decimals" overstates the variance, which matches only to one. The four datasets look nothing alike.

4. Programme

"""Experiment 4 — Data visualization techniques.

A plot cannot be asserted, but the numbers behind it can, and the point of a
plot is nearly always a number you could have missed. Each function here
computes what its chart shows and checks it; the images are written to plots/.

The centrepiece is Anscombe's quartet, which is the single best argument for
plotting before modelling.
"""
import pathlib

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

from fixtures import STUDY, churn, iris_frame

# [Changed: the charts went to output/, which now holds what each program printed;
# they are drawn into plots/, and the lab page shows them.]
OUT = pathlib.Path(__file__).parent / "plots"
OUT.mkdir(exist_ok=True)

# Anscombe's quartet: four datasets, identical summary statistics.
ANSCOMBE = {
    "I":   ([10, 8, 13, 9, 11, 14, 6, 4, 12, 7, 5],
            [8.04, 6.95, 7.58, 8.81, 8.33, 9.96, 7.24, 4.26, 10.84, 4.82, 5.68]),
    "II":  ([10, 8, 13, 9, 11, 14, 6, 4, 12, 7, 5],
            [9.14, 8.14, 8.74, 8.77, 9.26, 8.10, 6.13, 3.10, 9.13, 7.26, 4.74]),
    "III": ([10, 8, 13, 9, 11, 14, 6, 4, 12, 7, 5],
            [7.46, 6.77, 12.74, 7.11, 7.81, 8.84, 6.08, 5.39, 8.15, 6.42, 5.73]),
    "IV":  ([8, 8, 8, 8, 8, 8, 8, 19, 8, 8, 8],
            [6.58, 5.76, 7.71, 8.84, 8.47, 7.04, 5.25, 12.50, 5.56, 7.91, 6.89]),
}


def anscombes_quartet():
    """Four datasets. Same mean, variance, correlation and regression line."""
    stats = {}
    for name, (xs, ys) in ANSCOMBE.items():
        x, y = np.array(xs, dtype=float), np.array(ys, dtype=float)
        slope, intercept = np.polyfit(x, y, 1)
        stats[name] = (x.mean(), y.mean(), y.var(ddof=1),
                       np.corrcoef(x, y)[0, 1], slope, intercept)

    # The agreement is close but not exact, and the precision differs per
    # statistic. Assert exactly what is true, not the folklore:
    #   mean x   exact       9.0000 in all four
    #   slope    3 decimals  0.50009, 0.50000, 0.49973, 0.49991
    #   mean y   2 decimals  7.5009, 7.5009, 7.5000, 7.5009
    #   icept    2 decimals  3.00009, 3.00091, 3.00245, 3.00173
    #   r        2 decimals  0.81642, 0.81624, 0.81629, 0.81652
    #   var y    1 decimal   4.1273, 4.1276, 4.1226, 4.1232
    for name, s in stats.items():
        assert round(s[0], 4) == 9.0000, (name, s[0])        # mean x -- exact
        assert round(s[4], 3) == 0.500, (name, s[4])         # slope
        assert round(s[1], 2) == 7.50, (name, s[1])          # mean y
        assert round(s[5], 2) == 3.00, (name, s[5])          # intercept
        assert round(s[3], 2) == 0.82, (name, s[3])          # correlation
        assert round(s[2], 1) == 4.1, (name, s[2])           # var y

    fig, axes = plt.subplots(2, 2, figsize=(7, 6))
    for ax, (name, (xs, ys)) in zip(axes.ravel(), ANSCOMBE.items()):
        ax.scatter(xs, ys, color="#0f4c81")
        xx = np.linspace(3, 20, 2)
        ax.plot(xx, 3.0 + 0.5 * xx, color="#dc2626", linewidth=1)
        ax.set_title(f"Anscombe {name}")
        ax.set_xlim(2, 20); ax.set_ylim(2, 14)
    fig.suptitle("Identical statistics, four different datasets")
    fig.tight_layout()
    fig.savefig(OUT / "04_anscombe.png", dpi=110)
    plt.close(fig)

    print("  set  mean x  mean y   var y     r    slope  intercept")
    for name, s in stats.items():
        print(f"   {name:3s} {s[0]:7.2f} {s[1]:7.2f} {s[2]:7.2f} {s[3]:6.3f} "
              f"{s[4]:7.3f} {s[5]:9.2f}")
    print("       mean x is IDENTICAL, the slope agrees to three decimals, and")
    print("       mean y, intercept and r to two -- yet the four")
    print("       datasets look nothing alike: I is a genuine linear")
    print("       relationship, II is a parabola, III is a line plus one")
    print("       outlier, IV is a vertical stack plus one leverage point.")
    print("       PLOT BEFORE YOU MODEL. Summary statistics cannot see shape")


def histogram_and_the_bin_count():
    """The same data, four bin counts, four different impressions."""
    rng = np.random.default_rng(0)
    data = np.concatenate([rng.normal(20, 3, 200), rng.normal(35, 3, 200)])

    def interior_peaks(bins):
        hist, _ = np.histogram(data, bins=bins)
        return sum(1 for i in range(1, len(hist) - 1)
                   if hist[i] > hist[i - 1] and hist[i] > hist[i + 1])

    found = {bins: interior_peaks(bins) for bins in (2, 5, 15, 30, 60)}

    assert len(data) == 400
    assert found[2] == 0, "two bins cannot show ANY interior structure"
    assert found[5] == 2 and found[15] == 2, "the two real modes"
    assert found[30] == 4 and found[60] == 15, found
    assert found[60] > 7 * found[15], "60 bins invents peaks that are not there"

    fig, axes = plt.subplots(1, 4, figsize=(12, 3))
    for ax, bins in zip(axes, (2, 5, 15, 60)):
        ax.hist(data, bins=bins, color="#0f4c81")
        ax.set_title(f"{bins} bins")
    fig.tight_layout()
    fig.savefig(OUT / "04_histogram_bins.png", dpi=110)
    plt.close(fig)

    print("  a genuinely BIMODAL sample (peaks at 20 and 35), by bin count:")
    for bins, peaks in found.items():
        verdict = ("no structure visible" if peaks == 0 else
                   "correct" if peaks == 2 else "SPURIOUS peaks from noise")
        print(f"    {bins:2d} bins -> {peaks:2d} interior peak(s)   {verdict}")
    print("       2 bins shows nothing; 5 and 15 find both real modes; 60 finds")
    print("       FIFTEEN, which are sampling noise. The bin count is a CHOICE")
    print("       that changes the conclusion in both directions, which is why")
    print("       a histogram should never be the only thing you look at")


def boxplot_finds_outliers():
    """The IQR rule, computed -- Course 4's method, applied."""
    x = pd.Series([10, 12, 11, 13, 12, 11, 14, 12, 250])
    q1, q3 = x.quantile(0.25), x.quantile(0.75)
    iqr = q3 - q1
    lo, hi = q1 - 1.5 * iqr, q3 + 1.5 * iqr
    outliers = x[(x < lo) | (x > hi)]

    assert q1 == 11.0 and q3 == 13.0 and iqr == 2.0
    assert lo == 8.0 and hi == 16.0
    assert outliers.tolist() == [250]

    # The mean is destroyed by the outlier; the median is not.
    assert round(x.mean(), 4) == 38.3333
    assert x.median() == 12.0

    print(f"  Q1 {q1}, Q3 {q3}, IQR {iqr}  ->  fences [{lo}, {hi}]")
    print(f"  outliers: {outliers.tolist()}")
    print(f"  mean {x.mean():.4f} vs median {x.median():.1f}")
    print("       one value moved the mean by 26 and the median by 0. A box")
    print("       plot shows both the outlier and that robustness at once")


def scatter_matrix_and_correlation():
    """Which iris features are redundant -- the question a pair plot answers."""
    df, data = iris_frame()
    features = list(data.feature_names)
    corr = data.data.corr()

    petal = corr.loc["petal length (cm)", "petal width (cm)"]
    sepal_petal = corr.loc["sepal length (cm)", "petal length (cm)"]
    sepal_w = corr.loc["sepal length (cm)", "sepal width (cm)"]

    assert round(petal, 4) == 0.9629, round(petal, 4)
    assert round(sepal_petal, 4) == 0.8718, round(sepal_petal, 4)
    assert round(sepal_w, 4) == -0.1176, round(sepal_w, 4)
    assert petal > sepal_petal > abs(sepal_w)

    print("  iris feature correlations:")
    print(f"    petal length ~ petal width  {petal:+.4f}   <- nearly redundant")
    print(f"    sepal length ~ petal length {sepal_petal:+.4f}")
    print(f"    sepal length ~ sepal width  {sepal_w:+.4f}   <- almost none")
    print("       petal length and width carry nearly the same information,")
    print("       which is exactly why PCA compressed four columns to two")


def class_balance_should_be_plotted_first():
    """The first plot to make on any classification problem."""
    df = churn()
    counts = df["churned"].value_counts().sort_index()
    rate = df["churned"].mean()

    assert counts.tolist() == [340, 60]
    assert round(rate, 4) == 0.15
    assert round(counts[0] / len(df), 4) == 0.85, "the majority-class baseline"

    print(f"  class balance: {counts[0]} negative, {counts[1]} positive "
          f"({rate * 100:.0f}% positive)")
    print(f"  a model predicting 'never' scores {counts[0] / len(df) * 100:.0f}% accuracy")
    print("       plot this FIRST. It tells you what accuracy will mean before")
    print("       you fit anything, and it is one line of code")


def line_plot_needs_ordered_x():
    df = STUDY.sort_values("hours")
    assert df["hours"].is_monotonic_increasing
    assert df["score"].is_monotonic_increasing

    fig, ax = plt.subplots(figsize=(5, 3))
    ax.scatter(df["hours"], df["score"], color="#0f4c81")
    m, c = np.polyfit(df["hours"], df["score"], 1)
    ax.plot(df["hours"], m * df["hours"] + c, color="#dc2626")
    ax.set_xlabel("hours studied"); ax.set_ylabel("exam score")
    ax.set_title(f"score = {c:.2f} + {m:.2f} x hours")
    fig.tight_layout()
    fig.savefig(OUT / "04_scatter_fit.png", dpi=110)
    plt.close(fig)

    assert round(m, 4) == 4.3030 and round(c, 4) == 43.0303

    print(f"  study data: fitted line score = {c:.4f} + {m:.4f} x hours")
    print("       scatter plot for a RELATIONSHIP, with the fit drawn over it.")
    print("       Points only -- never join scattered points with lines")


def main():
    print("Experiment 4 -- Data visualization techniques")
    # Step 1: Plot Anscombe's quartet
    anscombes_quartet()
    # Step 2: Vary the histogram's bin count
    histogram_and_the_bin_count()
    # Step 3: Find outliers with a boxplot
    boxplot_finds_outliers()
    # Step 4: Read the scatter matrix and the correlations
    scatter_matrix_and_correlation()
    # Step 5: Plot the class balance first
    class_balance_should_be_plotted_first()
    # Step 6: Sort x before a line plot
    line_plot_needs_ordered_x()
    print(f"  charts written to {OUT.name}/")


if __name__ == "__main__":
    main()

5. Execution and Results

OUTPUT

Experiment 4 -- Data visualization techniques
  set  mean x  mean y   var y     r    slope  intercept
   I      9.00    7.50    4.13  0.816   0.500      3.00
   II     9.00    7.50    4.13  0.816   0.500      3.00
   III    9.00    7.50    4.12  0.816   0.500      3.00
   IV     9.00    7.50    4.12  0.817   0.500      3.00
       mean x is IDENTICAL, the slope agrees to three decimals, and
       mean y, intercept and r to two -- yet the four
       datasets look nothing alike: I is a genuine linear
       relationship, II is a parabola, III is a line plus one
       outlier, IV is a vertical stack plus one leverage point.
       PLOT BEFORE YOU MODEL. Summary statistics cannot see shape
  a genuinely BIMODAL sample (peaks at 20 and 35), by bin count:
     2 bins ->  0 interior peak(s)   no structure visible
     5 bins ->  2 interior peak(s)   correct
    15 bins ->  2 interior peak(s)   correct
    30 bins ->  4 interior peak(s)   SPURIOUS peaks from noise
    60 bins -> 15 interior peak(s)   SPURIOUS peaks from noise
       2 bins shows nothing; 5 and 15 find both real modes; 60 finds
       FIFTEEN, which are sampling noise. The bin count is a CHOICE
       that changes the conclusion in both directions, which is why
       a histogram should never be the only thing you look at
  Q1 11.0, Q3 13.0, IQR 2.0  ->  fences [8.0, 16.0]
  outliers: [250]
  mean 38.3333 vs median 12.0
       one value moved the mean by 26 and the median by 0. A box
       plot shows both the outlier and that robustness at once
  iris feature correlations:
    petal length ~ petal width  +0.9629   <- nearly redundant
    sepal length ~ petal length +0.8718
    sepal length ~ sepal width  -0.1176   <- almost none
       petal length and width carry nearly the same information,
       which is exactly why PCA compressed four columns to two
  class balance: 340 negative, 60 positive (15% positive)
  a model predicting 'never' scores 85% accuracy
       plot this FIRST. It tells you what accuracy will mean before
       you fit anything, and it is one line of code
  study data: fitted line score = 43.0303 + 4.3030 x hours
       scatter plot for a RELATIONSHIP, with the fit drawn over it.
       Points only -- never join scattered points with lines
  charts written to plots/

04_visualization.py: chart 1 of 3, drawn by the program

04_visualization.py: chart 2 of 3, drawn by the program

04_visualization.py: chart 3 of 3, drawn by the program

Also measured: the histogram bin count changes the conclusion in both directions — 2 bins show no structure, 5 and 15 find both real modes, and 60 bins find fifteen, which are sampling noise. Plus the IQR outlier rule, iris correlations (petal length ~ petal width 0.9629), and the class balance plot that should be the first thing you make.

The three charts above are Anscombe's quartet, the histogram at four bin counts, and the scatter with its fitted line. Changed: they were written to output/, which now holds what each program printed; the program writes them to plots/.

RESULT

Four datasets with the same summary statistics look nothing alike; 60 histogram bins find fifteen modes that are noise.

Experiment 5 — Maximum likelihood estimation

1. Question

Estimate parameters by maximum likelihood.

2. Aim

Find the MLE of a coin's p two ways, and connect least squares to likelihood.

3. Steps

  1. Estimate a coin's p by grid search and by calculus.
  2. See why logs are taken.
  3. Show least squares is the MLE under normal errors.
  4. Estimate a normal mean and variance.

THE RESULTS

4. Programme

"""Experiment 5 — Maximum likelihood estimation.

unit-3.md section 3.7 derives the MLE for a coin by hand and claims two things
this script checks numerically:

  * the MLE of a Bernoulli parameter is the sample proportion (7 heads in 10
    gives p-hat = 0.7)
  * least squares IS the MLE for linear regression under normal errors

The second is the connection worth knowing and the one rarely demonstrated.
"""
import numpy as np
from scipy import optimize, stats

from fixtures import STUDY, study_xy


def coin_mle_by_grid_and_by_calculus():
    """Ten flips, seven heads. The hand derivation says p-hat = 0.7."""
    heads, n = 7, 10

    def log_likelihood(p):
        return heads * np.log(p) + (n - heads) * np.log(1 - p)

    grid = np.linspace(0.001, 0.999, 999)
    numeric = grid[np.argmax([log_likelihood(p) for p in grid])]
    analytic = heads / n

    assert round(analytic, 4) == 0.7
    assert abs(numeric - analytic) < 0.002, (numeric, analytic)

    # The likelihood at the MLE beats every alternative -- the definition.
    assert log_likelihood(0.7) > log_likelihood(0.5)
    assert log_likelihood(0.7) > log_likelihood(0.9)

    print(f"  {heads} heads in {n} flips")
    print(f"    analytic MLE  (calculus)   p-hat = {analytic:.4f}")
    print(f"    numeric  MLE  (grid search) p-hat = {numeric:.4f}")
    print(f"    log-likelihood at p=0.5 {log_likelihood(0.5):8.4f}")
    print(f"                   at p=0.7 {log_likelihood(0.7):8.4f}  <- maximum")
    print(f"                   at p=0.9 {log_likelihood(0.9):8.4f}")
    print("       the MLE is the SAMPLE PROPORTION, which is what intuition")
    print("       already suggested -- MLE gives it a principled derivation")


def why_take_logs():
    """Products of many small probabilities underflow. Sums of logs do not."""
    p = 0.5
    n = 2000

    product = p ** n
    log_sum = n * np.log(p)

    assert product == 0.0, "underflowed to EXACTLY zero"
    assert log_sum < -1000 and np.isfinite(log_sum)

    print(f"  0.5 ** {n} as a float  = {product}   <- underflowed to zero")
    print(f"  {n} * ln(0.5)          = {log_sum:.4f}   <- finite and usable")
    print("       a likelihood is a PRODUCT of n probabilities each below 1.")
    print("       Beyond a few hundred observations it is exactly 0.0 in")
    print("       floating point, and every candidate parameter ties at zero.")
    print("       The log is monotonic, so the maximum is unchanged")


def least_squares_is_the_mle_under_normal_errors():
    """unit-3.md's claim, verified: two different derivations, one answer."""
    X, y = study_xy()
    x = X.ravel()

    # (a) Ordinary least squares, in closed form.
    x_bar, y_bar = x.mean(), y.mean()
    ls_slope = ((x - x_bar) * (y - y_bar)).sum() / ((x - x_bar) ** 2).sum()
    ls_intercept = y_bar - ls_slope * x_bar

    # (b) Maximum likelihood, assuming y ~ Normal(b0 + b1 x, sigma).
    def negative_log_likelihood(theta):
        b0, b1, log_sigma = theta
        sigma = np.exp(log_sigma)
        residual = y - (b0 + b1 * x)
        return -np.sum(stats.norm.logpdf(residual, loc=0, scale=sigma))

    fit = optimize.minimize(negative_log_likelihood, x0=[0.0, 1.0, 0.0],
                            method="Nelder-Mead",
                            options={"xatol": 1e-10, "fatol": 1e-10,
                                     "maxiter": 20000, "maxfev": 20000})
    mle_intercept, mle_slope, log_sigma = fit.x

    assert fit.success, fit.message
    assert round(ls_slope, 4) == 4.3030
    assert round(ls_intercept, 4) == 43.0303
    assert abs(mle_slope - ls_slope) < 1e-3, (mle_slope, ls_slope)
    assert abs(mle_intercept - ls_intercept) < 1e-2, (mle_intercept, ls_intercept)

    # And the MLE of sigma is the ROOT MEAN squared residual (divide by n),
    # not the unbiased estimate (divide by n-2).
    residual = y - (ls_intercept + ls_slope * x)
    sigma_mle = np.sqrt((residual ** 2).mean())
    assert abs(np.exp(log_sigma) - sigma_mle) < 1e-2

    print("                     slope    intercept")
    print(f"    least squares  {ls_slope:9.4f}  {ls_intercept:11.4f}")
    print(f"    maximum likelihood {mle_slope:5.4f}  {mle_intercept:11.4f}")
    print(f"    agree to within {abs(mle_slope - ls_slope):.2e} and "
          f"{abs(mle_intercept - ls_intercept):.2e}")
    print(f"    MLE of sigma = RMS residual = {sigma_mle:.4f}")
    print("       TWO DIFFERENT DERIVATIONS, ONE ANSWER. Minimising squared")
    print("       error and maximising likelihood are the same thing when the")
    print("       errors are normal -- which is why least squares is not an")
    print("       arbitrary choice but a consequence of an assumption")


def mle_for_a_normal_mean_and_variance():
    """The other standard derivation: mu-hat is the sample mean."""
    rng = np.random.default_rng(0)
    sample = rng.normal(50, 8, 500)

    mu_hat = sample.mean()
    sigma_hat = np.sqrt(((sample - mu_hat) ** 2).mean())    # divides by n
    sigma_unbiased = sample.std(ddof=1)                     # divides by n-1

    assert abs(mu_hat - 50) < 1.0
    assert sigma_hat < sigma_unbiased, "the MLE of sigma is BIASED downward"
    ratio = sigma_unbiased / sigma_hat
    assert abs(ratio - np.sqrt(500 / 499)) < 1e-12

    print(f"  500 draws from Normal(50, 8):")
    print(f"    mu-hat    (MLE)          {mu_hat:.4f}   = the sample mean")
    print(f"    sigma-hat (MLE, /n)      {sigma_hat:.4f}")
    print(f"    sigma     (unbiased, /n-1) {sigma_unbiased:.4f}")
    print(f"    ratio = sqrt(n/(n-1)) = {ratio:.6f}")
    print("       the MLE of the mean is the sample mean, but the MLE of the")
    print("       VARIANCE divides by n and is biased low. Course 4 divided by")
    print("       n-1 for exactly this reason -- MLE is not automatically")
    print("       unbiased, and knowing that is worth a mark")


def main():
    print("Experiment 5 -- Maximum likelihood estimation")
    # Step 1: Estimate a coin's p by grid search and by calculus
    coin_mle_by_grid_and_by_calculus()
    # Step 2: See why logs are taken
    why_take_logs()
    # Step 3: Show least squares is the MLE under normal errors
    least_squares_is_the_mle_under_normal_errors()
    # Step 4: Estimate a normal mean and variance
    mle_for_a_normal_mean_and_variance()


if __name__ == "__main__":
    main()

5. Execution and Results

OUTPUT

Experiment 5 -- Maximum likelihood estimation
  7 heads in 10 flips
    analytic MLE  (calculus)   p-hat = 0.7000
    numeric  MLE  (grid search) p-hat = 0.7000
    log-likelihood at p=0.5  -6.9315
                   at p=0.7  -6.1086  <- maximum
                   at p=0.9  -7.6453
       the MLE is the SAMPLE PROPORTION, which is what intuition
       already suggested -- MLE gives it a principled derivation
  0.5 ** 2000 as a float  = 0.0   <- underflowed to zero
  2000 * ln(0.5)          = -1386.2944   <- finite and usable
       a likelihood is a PRODUCT of n probabilities each below 1.
       Beyond a few hundred observations it is exactly 0.0 in
       floating point, and every candidate parameter ties at zero.
       The log is monotonic, so the maximum is unchanged
                     slope    intercept
    least squares     4.3030      43.0303
    maximum likelihood 4.3030      43.0303
    agree to within 6.66e-09 and 3.13e-08
    MLE of sigma = RMS residual = 0.8015
       TWO DIFFERENT DERIVATIONS, ONE ANSWER. Minimising squared
       error and maximising likelihood are the same thing when the
       errors are normal -- which is why least squares is not an
       arbitrary choice but a consequence of an assumption
  500 draws from Normal(50, 8):
    mu-hat    (MLE)          49.7849   = the sample mean
    sigma-hat (MLE, /n)      8.1088
    sigma     (unbiased, /n-1) 8.1169
    ratio = sqrt(n/(n-1)) = 1.001002
       the MLE of the mean is the sample mean, but the MLE of the
       VARIANCE divides by n and is biased low. Course 4 divided by
       n-1 for exactly this reason -- MLE is not automatically
       unbiased, and knowing that is worth a mark

RESULT

p̂ = 0.7000 by calculus and by grid search; least squares and maximum likelihood agree to 6.7 × 10⁻⁹.

Experiment 6 — Simple and multiple linear regression

1. Question

Fit simple and multiple linear regression models.

2. Aim

Reproduce Statistical Foundations' regression, then see extrapolation, multicollinearity and overfitting go wrong.

3. Steps

  1. Fit the simple regression, and check it against Statistical Foundations.
  2. Predict, and see the extrapolation trap.
  3. Fit a multiple regression with collinear predictors.
  4. Compare R² with adjusted R².
  5. Raise the polynomial degree.
  6. Compare Ridge with Lasso.

THE RESULTS

Reproduces Statistical Foundations for Data Science exactly (slope 4.3030, intercept 43.0303, R² 0.9958, residuals summing to 7.1e-15, and R² = r²), then:

Result Figure
Extrapolation x = 50 predicts 258.1818 marks out of 100
Multicollinearity Two predictors correlating 0.9983: R² unchanged at 0.9767, but the x₁ coefficient moves from 2.9887 to 3.4200 and x₂ picks up −0.4419. Prediction fine, interpretation destroyed
Polynomial degree 9 on 10 points R² = 1.000000 — exact interpolation, overfitting made visible
…without standardising R² = 0.9815, worse than a straight line, because the condition number reaches 2.69 × 10¹³
Lasso vs Ridge With 12 predictors of which 3 matter: Ridge zeroes 0, Lasso zeroes 9

4. Programme

"""Experiment 6 — Simple and multiple linear regression.

Refits COURSE 4's worked example with scikit-learn and asserts it reproduces
the hand-computed slope 4.3030, intercept 43.0303 and R^2 0.9958. If the two
courses ever disagree, this test says so.

Then multiple regression, multicollinearity, and the polynomial degree /
conditioning result from unit-3.md section 3.5.
"""
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression, Lasso, Ridge
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import PolynomialFeatures, StandardScaler

from fixtures import RANDOM_STATE, STUDY, study_xy


def simple_linear_regression_matches_course_4():
    X, y = study_xy()
    model = LinearRegression().fit(X, y)
    pred = model.predict(X)

    slope = float(model.coef_[0])
    intercept = float(model.intercept_)

    assert round(slope, 4) == 4.3030, slope
    assert round(intercept, 4) == 43.0303, intercept
    assert round(model.score(X, y), 4) == 0.9958
    assert round(float(np.sqrt(mean_squared_error(y, pred))), 4) == 0.8015
    assert round(float(mean_absolute_error(y, pred)), 4) == 0.7394

    # Two checks Course 4 gave, verified rather than asserted in prose.
    assert abs((y - pred).sum()) < 1e-10, "least-squares residuals sum to zero"
    r = float(np.corrcoef(X.ravel(), y)[0, 1])
    assert round(r ** 2, 4) == round(model.score(X, y), 4), "R^2 = r^2"

    print(f"  slope     {slope:.4f}   (Course 4 computed 4.3030)")
    print(f"  intercept {intercept:.4f}  (Course 4 computed 43.0303)")
    print(f"  R^2       {model.score(X, y):.4f}   (Course 4 computed 0.9958)")
    print(f"  RMSE {np.sqrt(mean_squared_error(y, pred)):.4f}   "
          f"MAE {mean_absolute_error(y, pred):.4f}")
    print(f"  residuals sum to {(y - pred).sum():.1e}   r^2 = {r ** 2:.4f} = R^2")
    print("       scikit-learn reproduces Course 4's hand arithmetic exactly")
    return model


def prediction_and_the_extrapolation_trap(model):
    inside = float(model.predict([[7.5]])[0])
    outside = float(model.predict([[50]])[0])

    assert round(inside, 4) == 75.3030, inside
    assert round(outside, 4) == 258.1818, outside
    assert outside > 100, "a percentage score above 100 -- impossible"

    print(f"  x = 7.5 (inside the observed range 2-11) -> {inside:.4f} marks")
    print(f"  x = 50  (far outside it)                 -> {outside:.4f} marks")
    print("       258 marks out of 100. Nothing in the code warns you; the")
    print("       model has no evidence beyond the range it was fitted on")


def multiple_regression_and_multicollinearity():
    """A duplicate-ish predictor destabilises coefficients, not predictions."""
    rng = np.random.default_rng(RANDOM_STATE)
    n = 200
    x1 = rng.normal(0, 1, n)
    x2 = x1 * 0.98 + rng.normal(0, 0.05, n)      # almost a copy of x1
    x3 = rng.normal(0, 1, n)
    y = 3 * x1 + 2 * x3 + rng.normal(0, 0.5, n)

    clean = LinearRegression().fit(np.c_[x1, x3], y)
    collinear = LinearRegression().fit(np.c_[x1, x2, x3], y)

    corr = float(np.corrcoef(x1, x2)[0, 1])
    assert corr > 0.99, corr

    # Predictions barely change; the COEFFICIENTS do.
    r2_clean = clean.score(np.c_[x1, x3], y)
    r2_coll = collinear.score(np.c_[x1, x2, x3], y)
    assert abs(r2_clean - r2_coll) < 0.01, (r2_clean, r2_coll)
    assert abs(collinear.coef_[0] - 3.0) > abs(clean.coef_[0] - 3.0), \
        "the x1 coefficient moved away from its true value of 3"

    # Ridge is the standard remedy.
    ridge = make_pipeline(StandardScaler(), Ridge(alpha=1.0)).fit(
        np.c_[x1, x2, x3], y)

    print(f"  x1 and x2 correlate {corr:.4f}; the truth is y = 3*x1 + 2*x3")
    print(f"    without x2: coefficients {np.round(clean.coef_, 4)}  R^2 {r2_clean:.4f}")
    print(f"    with    x2: coefficients {np.round(collinear.coef_, 4)}  R^2 {r2_coll:.4f}")
    print(f"    ridge      : coefficients {np.round(ridge[-1].coef_, 4)}")
    print("       R^2 barely moved -- PREDICTION is fine. The coefficients split")
    print("       the shared effect arbitrarily between x1 and x2, so they can")
    print("       no longer be interpreted. Multicollinearity breaks explanation,")
    print("       not prediction, and Ridge is the standard remedy")


def r2_never_decreases_but_adjusted_r2_does():
    """Add PURE NOISE as predictors, over many random draws.

    One draw proves nothing here, because adjusted R^2 can rise by chance.
    The claim that is actually true is about the DISTRIBUTION: R^2 rises every
    single time, while adjusted R^2 rises only about as often as it falls and
    has no upward drift. That is what this measures.
    """
    n, trials, extra = 100, 300, 5

    def adjusted(r2, p):
        return 1 - (1 - r2) * (n - 1) / (n - p - 1)

    rose_r2 = rose_adj = 0
    deltas_r2, deltas_adj = [], []
    for seed in range(trials):
        rng = np.random.default_rng(seed)
        x = rng.normal(0, 1, n)
        y = 3 * x + rng.normal(0, 1, n)

        X1 = x.reshape(-1, 1)
        r2_one = LinearRegression().fit(X1, y).score(X1, y)

        Xk = np.c_[x, rng.normal(0, 1, (n, extra))]     # pure noise columns
        r2_many = LinearRegression().fit(Xk, y).score(Xk, y)

        rose_r2 += r2_many > r2_one
        rose_adj += adjusted(r2_many, 1 + extra) > adjusted(r2_one, 1)
        deltas_r2.append(r2_many - r2_one)
        deltas_adj.append(adjusted(r2_many, 1 + extra) - adjusted(r2_one, 1))

    assert rose_r2 == trials, (rose_r2, trials)
    assert rose_adj < trials // 2, rose_adj
    assert np.mean(deltas_r2) > 0.004
    assert abs(np.mean(deltas_adj)) < 0.001, np.mean(deltas_adj)
    assert min(deltas_r2) > 0, "R^2 never fell, not once"

    print(f"  {trials} random datasets, adding {extra} columns of PURE NOISE:")
    print(f"    R^2 rose in          {rose_r2:3d}/{trials}  "
          f"({rose_r2 / trials * 100:5.1f}%)   mean change {np.mean(deltas_r2):+.6f}")
    print(f"    adjusted R^2 rose in {rose_adj:3d}/{trials}  "
          f"({rose_adj / trials * 100:5.1f}%)   mean change {np.mean(deltas_adj):+.6f}")
    print(f"    smallest R^2 change seen: {min(deltas_r2):+.6f}  <- still positive")
    print("       R^2 rose EVERY time, because adding a column can only give")
    print("       least squares more freedom. Adjusted R^2 penalises the extra")
    print("       parameters, rises about as often as it falls, and has no")
    print("       upward drift -- which is why it can compare models of")
    print("       different sizes and R^2 cannot")


def polynomial_degree_and_conditioning():
    """unit-3.md 3.5: degree 9 interpolates 10 points -- if you standardise."""
    X, y = study_xy()

    raw, scaled, conditions = {}, {}, {}
    for d in (1, 2, 3, 5, 9):
        raw[d] = make_pipeline(PolynomialFeatures(d),
                               LinearRegression()).fit(X, y).score(X, y)
        scaled[d] = make_pipeline(StandardScaler(), PolynomialFeatures(d),
                                  LinearRegression()).fit(X, y).score(X, y)
        conditions[d] = float(np.linalg.cond(PolynomialFeatures(d).fit_transform(X)))

    assert round(raw[1], 6) == 0.995812
    assert round(scaled[9], 6) == 1.000000, "10 points, degree 9 -> exact fit"
    assert round(raw[9], 6) == 0.981546, "unscaled, it fails NUMERICALLY"
    assert raw[9] < raw[1], "and ends up worse than a straight line"
    assert conditions[9] > 1e13 and conditions[1] < 1e2

    print(f"    {'degree':>7} {'R2 unscaled':>13} {'R2 scaled':>11} {'condition no.':>15}")
    for d in (1, 2, 3, 5, 9):
        print(f"    {d:>7} {raw[d]:13.6f} {scaled[d]:11.6f} {conditions[d]:15.2e}")
    print("       standardised, degree 9 fits 10 points EXACTLY (R^2 = 1.0) --")
    print("       9 coefficients plus an intercept for 10 points. That is")
    print("       overfitting made visible.")
    print("       UNSTANDARDISED it scores 0.9815, worse than a straight line,")
    print("       because the condition number reaches 2.7e13 and the solve")
    print("       loses its precision. Standardise before PolynomialFeatures")


def ridge_and_lasso():
    """L2 shrinks; L1 shrinks to exactly zero and therefore selects."""
    rng = np.random.default_rng(RANDOM_STATE)
    n, p = 100, 12
    X = rng.normal(0, 1, (n, p))
    true = np.zeros(p)
    true[:3] = [4.0, -3.0, 2.0]                  # only 3 of 12 matter
    y = X @ true + rng.normal(0, 0.5, n)

    ols = LinearRegression().fit(X, y)
    ridge = Ridge(alpha=10.0).fit(X, y)
    lasso = Lasso(alpha=0.3).fit(X, y)

    ols_zero = int(np.sum(np.abs(ols.coef_) < 1e-8))
    ridge_zero = int(np.sum(np.abs(ridge.coef_) < 1e-8))
    lasso_zero = int(np.sum(np.abs(lasso.coef_) < 1e-8))

    assert ols_zero == 0 and ridge_zero == 0
    assert lasso_zero >= 7, lasso_zero
    assert all(abs(lasso.coef_[i]) > 1.0 for i in range(3)), \
        "Lasso kept the three real predictors"

    print(f"  12 predictors, only 3 of which matter:")
    print(f"    OLS   : {ols_zero:2d} coefficients exactly zero")
    print(f"    Ridge : {ridge_zero:2d} coefficients exactly zero  (shrunk, not removed)")
    print(f"    Lasso : {lasso_zero:2d} coefficients exactly zero  <- it SELECTED")
    print(f"    Lasso's first three: {np.round(lasso.coef_[:3], 4)}  (true 4, -3, 2)")
    print("       that is the difference: L2 shrinks toward zero, L1 shrinks TO")
    print("       zero, which makes Lasso an embedded feature-selection method")


def main():
    print("Experiment 6 -- Simple and multiple linear regression")
    # Step 1: Fit the simple regression, and check it against Statistical Foundations
    model = simple_linear_regression_matches_course_4()
    # Step 2: Predict, and see the extrapolation trap
    prediction_and_the_extrapolation_trap(model)
    # Step 3: Fit a multiple regression with collinear predictors
    multiple_regression_and_multicollinearity()
    # Step 4: Compare R² with adjusted R²
    r2_never_decreases_but_adjusted_r2_does()
    # Step 5: Raise the polynomial degree
    print("  polynomial degree, with and without standardising:")
    polynomial_degree_and_conditioning()
    # Step 6: Compare Ridge with Lasso
    ridge_and_lasso()


if __name__ == "__main__":
    main()

5. Execution and Results

OUTPUT

Experiment 6 -- Simple and multiple linear regression
  slope     4.3030   (Course 4 computed 4.3030)
  intercept 43.0303  (Course 4 computed 43.0303)
  R^2       0.9958   (Course 4 computed 0.9958)
  RMSE 0.8015   MAE 0.7394
  residuals sum to 7.1e-15   r^2 = 0.9958 = R^2
       scikit-learn reproduces Course 4's hand arithmetic exactly
  x = 7.5 (inside the observed range 2-11) -> 75.3030 marks
  x = 50  (far outside it)                 -> 258.1818 marks
       258 marks out of 100. Nothing in the code warns you; the
       model has no evidence beyond the range it was fitted on
  x1 and x2 correlate 0.9983; the truth is y = 3*x1 + 2*x3
    without x2: coefficients [2.9887 2.0445]  R^2 0.9767
    with    x2: coefficients [ 3.42   -0.4419  2.0452]  R^2 0.9767
    ridge      : coefficients [1.7464 0.875  2.0607]
       R^2 barely moved -- PREDICTION is fine. The coefficients split
       the shared effect arbitrarily between x1 and x2, so they can
       no longer be interpreted. Multicollinearity breaks explanation,
       not prediction, and Ridge is the standard remedy
  300 random datasets, adding 5 columns of PURE NOISE:
    R^2 rose in          300/300  (100.0%)   mean change +0.005016
    adjusted R^2 rose in 119/300  ( 39.7%)   mean change -0.000171
    smallest R^2 change seen: +0.000299  <- still positive
       R^2 rose EVERY time, because adding a column can only give
       least squares more freedom. Adjusted R^2 penalises the extra
       parameters, rises about as often as it falls, and has no
       upward drift -- which is why it can compare models of
       different sizes and R^2 cannot
  polynomial degree, with and without standardising:
     degree   R2 unscaled   R2 scaled   condition no.
          1      0.995812    0.995812        1.79e+01
          2      0.995817    0.995817        3.54e+02
          3      0.995865    0.995865        8.16e+03
          5      0.996327    0.996327        6.34e+06
          9      0.981546    1.000000        2.69e+13
       standardised, degree 9 fits 10 points EXACTLY (R^2 = 1.0) --
       9 coefficients plus an intercept for 10 points. That is
       overfitting made visible.
       UNSTANDARDISED it scores 0.9815, worse than a straight line,
       because the condition number reaches 2.7e13 and the solve
       loses its precision. Standardise before PolynomialFeatures
  12 predictors, only 3 of which matter:
    OLS   :  0 coefficients exactly zero
    Ridge :  0 coefficients exactly zero  (shrunk, not removed)
    Lasso :  9 coefficients exactly zero  <- it SELECTED
    Lasso's first three: [ 3.7121 -2.7829  1.7833]  (true 4, -3, 2)
       that is the difference: L2 shrinks toward zero, L1 shrinks TO
       zero, which makes Lasso an embedded feature-selection method

THE R² DEMONSTRATION IS DONE PROPERLY

One dataset proves nothing, because adjusted R² can rise by chance. Over 300 random datasets, adding 5 columns of pure noise:

Rose in Mean change
R² 300/300 (100%) +0.005016
Adjusted R² 119/300 (39.7%) −0.000171

R² rose every single time. The smallest change seen was still +0.000299.

RESULT

The slope 4.3030, intercept 43.0303 and R² 0.9958 reproduce exactly; R² rose in 300 of 300 datasets when noise was added, adjusted R² in 119.

Experiment 7 — Logistic regression

1. Question

Classify customers as churners with logistic regression.

2. Aim

Score the baseline first, fit the model, read its odds ratios, and move the threshold.

3. Steps

  1. Score the baseline first.
  2. Fit the logistic regression.
  3. Read the odds ratios.
  4. Move the decision threshold.
  5. Find the ROC curve's endpoints.

THE RESULTS

The baseline first, as Unit 2 §2.5 insists:

Model Accuracy Recall
DummyClassifier("most_frequent") 0.8500 0.0000
Logistic regression 0.9400 0.7333

85% accurate and it identifies not one churner.

Confusion matrix TP 11, FP 2, FN 4, TN 83, with precision 0.8462 (11/13), recall 0.7333 (11/15), F1 0.7857 and AUC 0.9882 — each recomputed by hand from the four counts.

Odds ratios: support_calls has coefficient 2.2656, so e^2.2656 = 9.6367 — one extra standard deviation of calls multiplies the odds of churning by 9.64. tenure_months gives 0.0988, i.e. ×0.10.

4. Programme

"""Experiment 7 — Logistic regression.

Reproduces unit-2.md section 2.5's baseline argument and unit-3.md section
3.6's odds-ratio interpretation. The headline result is the one worth
remembering: a model that predicts nobody churns scores 0.85 accuracy and
0.00 recall.
"""
import numpy as np
from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (accuracy_score, confusion_matrix, f1_score,
                             precision_score, recall_score, roc_auc_score,
                             roc_curve)
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

from fixtures import RANDOM_STATE, churn

DF = churn()
FEATURES = ["tenure_months", "support_calls", "irrelevant"]
X = DF[FEATURES].to_numpy()
Y = DF["churned"].to_numpy()
XTR, XTE, YTR, YTE = train_test_split(
    X, Y, test_size=0.25, random_state=RANDOM_STATE, stratify=Y)


def the_baseline_first():
    """unit-2.md 2.5: fit a DummyClassifier before anything else."""
    dummy = DummyClassifier(strategy="most_frequent").fit(XTR, YTR)
    pred = dummy.predict(XTE)

    acc = accuracy_score(YTE, pred)
    rec = recall_score(YTE, pred, zero_division=0)

    assert round(DF["churned"].mean(), 4) == 0.15
    assert len(YTE) == 100 and int(YTE.sum()) == 15
    assert round(acc, 4) == 0.85, acc
    assert rec == 0.0
    assert set(pred) == {0}, "it predicts the majority class and nothing else"

    print(f"  base rate: {DF['churned'].mean() * 100:.0f}% positive "
          f"({int(YTE.sum())} of {len(YTE)} in the test set)")
    print(f"  DummyClassifier('most_frequent'):")
    print(f"    accuracy {acc:.4f}   recall {rec:.4f}")
    print("       85% accurate, and it identifies NOT ONE churner. Any accuracy")
    print("       you report from here must be compared against this number")
    return acc


def logistic_regression_fitted(dummy_accuracy):
    model = make_pipeline(
        StandardScaler(),
        LogisticRegression(random_state=RANDOM_STATE)).fit(XTR, YTR)
    pred = model.predict(XTE)
    proba = model.predict_proba(XTE)[:, 1]

    acc = accuracy_score(YTE, pred)
    prec = precision_score(YTE, pred, zero_division=0)
    rec = recall_score(YTE, pred)
    f1 = f1_score(YTE, pred)
    auc = roc_auc_score(YTE, proba)
    tn, fp, fn, tp = confusion_matrix(YTE, pred).ravel()

    assert round(acc, 4) == 0.94
    assert round(prec, 4) == 0.8462
    assert round(rec, 4) == 0.7333
    assert round(f1, 4) == 0.7857
    assert round(auc, 4) == 0.9882
    assert (tn, fp, fn, tp) == (83, 2, 4, 11), (tn, fp, fn, tp)
    assert tp + fn == 15 and tn + fp == 85

    # The metrics, recomputed by hand from the four counts.
    assert round(tp / (tp + fp), 4) == round(prec, 4)
    assert round(tp / (tp + fn), 4) == round(rec, 4)
    assert round((tp + tn) / len(YTE), 4) == round(acc, 4)

    print(f"  confusion matrix: TP {tp}  FP {fp}  FN {fn}  TN {tn}")
    print(f"    accuracy  {acc:.4f}   ({tp}+{tn})/{len(YTE)}")
    print(f"    precision {prec:.4f}   {tp}/{tp + fp}")
    print(f"    recall    {rec:.4f}   {tp}/{tp + fn}")
    print(f"    F1        {f1:.4f}")
    print(f"    AUC       {auc:.4f}")
    print(f"  against the baseline: accuracy {dummy_accuracy:.4f} -> {acc:.4f}, "
          f"a gain of {acc - dummy_accuracy:+.4f}")
    print(f"                        AUC      0.5000 -> {auc:.4f}")
    print("       accuracy COMPRESSES the difference into 9 points. AUC shows")
    print("       it properly, which is why AUC survives imbalance better")
    return model, proba


def odds_ratios(model):
    """unit-3.md 3.6: the model is linear in the LOG-ODDS."""
    logistic = model[-1]
    coefs = logistic.coef_[0]
    odds_ratio = np.exp(coefs)

    # Features were standardised, so a coefficient is 'per 1 sd'.
    named = dict(zip(FEATURES, zip(coefs, odds_ratio)))

    assert named["support_calls"][0] > 0, "more calls -> MORE likely to churn"
    assert named["tenure_months"][0] < 0, "longer tenure -> LESS likely"
    assert named["support_calls"][1] > 1.0 and named["tenure_months"][1] < 1.0
    assert abs(named["irrelevant"][0]) < 0.5, \
        "the noise feature got a coefficient near zero, as it should"

    print(f"  {'feature':16s} {'coefficient':>12s} {'odds ratio':>12s}  reading")
    for name, (b, orr) in named.items():
        direction = ("x{:.2f} odds per +1 sd".format(orr) if orr > 1
                     else "x{:.2f} odds per +1 sd".format(orr))
        print(f"  {name:16s} {b:12.4f} {orr:12.4f}  {direction}")
    print("       coefficients are in LOG-ODDS; exponentiate for an odds ratio.")
    print(f"       support_calls: e^{named['support_calls'][0]:.4f} = "
          f"{named['support_calls'][1]:.4f}, so one extra standard deviation of")
    print(f"       calls multiplies the ODDS of churning by "
          f"{named['support_calls'][1]:.2f}")
    print("       ODDS ARE NOT PROBABILITY: odds of 2.0 means p = 2/3")


def the_threshold_is_a_choice(proba):
    """Moving the cut-off trades precision against recall."""
    rows = []
    for t in (0.10, 0.30, 0.50, 0.70, 0.90):
        pred = (proba >= t).astype(int)
        rows.append((t,
                     accuracy_score(YTE, pred),
                     precision_score(YTE, pred, zero_division=0),
                     recall_score(YTE, pred),
                     f1_score(YTE, pred, zero_division=0)))

    at_50 = [r for r in rows if r[0] == 0.50][0]
    at_10 = [r for r in rows if r[0] == 0.10][0]
    at_90 = [r for r in rows if r[0] == 0.90][0]

    assert round(at_50[1], 4) == 0.94
    assert at_10[3] > at_50[3], "a lower threshold catches MORE churners"
    assert at_10[2] < at_50[2], "at the cost of precision"
    assert at_90[2] >= at_50[2], "a higher threshold is more precise"
    assert at_90[3] <= at_50[3], "and catches fewer"

    print(f"    {'threshold':>10} {'accuracy':>9} {'precision':>10} "
          f"{'recall':>8} {'F1':>7}")
    for t, a, p, r, f in rows:
        mark = "  <- the default" if t == 0.50 else ""
        print(f"    {t:10.2f} {a:9.4f} {p:10.4f} {r:8.4f} {f:7.4f}{mark}")
    print("       ONE model, five different classifiers. The 0.5 cut-off is a")
    print("       convention, not part of the model. Lower it to catch more")
    print("       churners; raise it to be surer of the ones you flag.")
    print("       Choose it from the COST of each error, not from habit")


def roc_curve_endpoints(proba):
    """The curve every threshold traces out."""
    fpr, tpr, thresholds = roc_curve(YTE, proba)
    auc = roc_auc_score(YTE, proba)

    assert round(fpr[0], 6) == 0.0 and round(tpr[0], 6) == 0.0
    assert round(fpr[-1], 6) == 1.0 and round(tpr[-1], 6) == 1.0
    assert round(auc, 4) == 0.9882
    assert auc > 0.5, "0.5 would be a coin flip"

    print(f"  ROC runs from (0,0) to (1,1) through {len(thresholds)} thresholds")
    print(f"  AUC {auc:.4f}   (0.5 = random, 1.0 = perfect or leaking)")
    print("       AUC is THRESHOLD-INDEPENDENT, which is exactly why it is")
    print("       reported on imbalanced problems where accuracy is not")


def main():
    print("Experiment 7 -- Logistic regression")
    # Step 1: Score the baseline first
    dummy_accuracy = the_baseline_first()
    # Step 2: Fit the logistic regression
    model, proba = logistic_regression_fitted(dummy_accuracy)
    # Step 3: Read the odds ratios
    odds_ratios(model)
    # Step 4: Move the decision threshold
    print("  moving the decision threshold:")
    the_threshold_is_a_choice(proba)
    # Step 5: Find the ROC curve's endpoints
    roc_curve_endpoints(proba)


if __name__ == "__main__":
    main()

5. Execution and Results

OUTPUT

Experiment 7 -- Logistic regression
  base rate: 15% positive (15 of 100 in the test set)
  DummyClassifier('most_frequent'):
    accuracy 0.8500   recall 0.0000
       85% accurate, and it identifies NOT ONE churner. Any accuracy
       you report from here must be compared against this number
  confusion matrix: TP 11  FP 2  FN 4  TN 83
    accuracy  0.9400   (11+83)/100
    precision 0.8462   11/13
    recall    0.7333   11/15
    F1        0.7857
    AUC       0.9882
  against the baseline: accuracy 0.8500 -> 0.9400, a gain of +0.0900
                        AUC      0.5000 -> 0.9882
       accuracy COMPRESSES the difference into 9 points. AUC shows
       it properly, which is why AUC survives imbalance better
  feature           coefficient   odds ratio  reading
  tenure_months         -2.3145       0.0988  x0.10 odds per +1 sd
  support_calls          2.2656       9.6367  x9.64 odds per +1 sd
  irrelevant            -0.2511       0.7779  x0.78 odds per +1 sd
       coefficients are in LOG-ODDS; exponentiate for an odds ratio.
       support_calls: e^2.2656 = 9.6367, so one extra standard deviation of
       calls multiplies the ODDS of churning by 9.64
       ODDS ARE NOT PROBABILITY: odds of 2.0 means p = 2/3
  moving the decision threshold:
     threshold  accuracy  precision   recall      F1
          0.10    0.9400     0.7143   1.0000  0.8333
          0.30    0.9300     0.7857   0.7333  0.7586
          0.50    0.9400     0.8462   0.7333  0.7857  <- the default
          0.70    0.9400     1.0000   0.6000  0.7500
          0.90    0.8900     1.0000   0.2667  0.4211
       ONE model, five different classifiers. The 0.5 cut-off is a
       convention, not part of the model. Lower it to catch more
       churners; raise it to be surer of the ones you flag.
       Choose it from the COST of each error, not from habit
  ROC runs from (0,0) to (1,1) through 10 thresholds
  AUC 0.9882   (0.5 = random, 1.0 = perfect or leaking)
       AUC is THRESHOLD-INDEPENDENT, which is exactly why it is
       reported on imbalanced problems where accuracy is not

THE THRESHOLD TABLE IS THE BEST ARTEFACT IN THIS EXPERIMENT

threshold accuracy precision recall F1
0.10 0.9400 0.7143 1.0000 0.8333
0.30 0.9300 0.7857 0.7333 0.7586
0.50 0.9400 0.8462 0.7333 0.7857
0.70 0.9400 1.0000 0.6000 0.7500
0.90 0.8900 1.0000 0.2667 0.4211

One model, five different classifiers. At 0.10 it catches every churner; at 0.90 it is never wrong about the ones it flags. The 0.5 cut-off is a convention, not part of the model.

RESULT

85% accuracy from the baseline catches no churner; the model reaches 0.9400 accuracy, recall 0.7333 and AUC 0.9882.

Experiment 8 — Decision tree classification

1. Question

Classify with a decision tree, and control its depth.

2. Aim

Compute entropy and gain by hand, then watch depth trade training accuracy against test accuracy.

3. Steps

  1. Compute entropy and gain by hand.
  2. Vary max_depth.
  3. Print a shallow tree.
  4. Read the feature importances.
  5. Cross-validate.
  6. Grow a tree on iris.

THE RESULTS

Data Mining's entropy arithmetic first — parent 0.9403, weighted children 0.6935, gain 0.2467 — then the machine-learning question:

max_depth train test gap leaves
1 0.9533 0.9200 +0.0333 2
3 0.9800 0.9400 +0.0400 5
5 0.9967 0.9500 +0.0467 10
10 1.0000 0.9400 +0.0600 11
None 1.0000 0.9400 +0.0600 11

Training accuracy reaches a perfect 1.0000 and test accuracy falls.

4. Programme

"""Experiment 8 — Decision tree classification.

Course 8 traced ID3's entropy and information gain by hand. This experiment
asks the machine-learning question instead: is the tree overfitting, and how
would you know? unit-2.md section 2.3's max_depth table is reproduced here.
"""
import numpy as np
from sklearn.model_selection import (StratifiedKFold, cross_val_score,
                                     train_test_split)
from sklearn.tree import DecisionTreeClassifier, export_text

from fixtures import RANDOM_STATE, churn, iris_frame

DF = churn()
FEATURES = ["tenure_months", "support_calls", "irrelevant"]
X = DF[FEATURES].to_numpy()
Y = DF["churned"].to_numpy()
XTR, XTE, YTR, YTE = train_test_split(
    X, Y, test_size=0.25, random_state=RANDOM_STATE, stratify=Y)


def entropy_and_gain_by_hand():
    """Course 8's arithmetic, verified -- so the two courses agree."""
    def entropy(counts):
        total = sum(counts)
        return -sum((c / total) * np.log2(c / total) for c in counts if c)

    # A 9-yes / 5-no parent, the classic worked example.
    parent = entropy([9, 5])
    assert round(parent, 4) == 0.9403, round(parent, 4)

    # Split into (2 yes, 3 no), (4 yes, 0 no), (3 yes, 2 no).
    children = [([2, 3], 5), ([4, 0], 4), ([3, 2], 5)]
    weighted = sum(n / 14 * entropy(c) for c, n in children)
    gain = parent - weighted

    assert round(entropy([4, 0]), 10) == 0.0, "a pure node has ZERO entropy"
    assert round(weighted, 4) == 0.6935, round(weighted, 4)
    assert round(gain, 4) == 0.2467, round(gain, 4)

    print(f"  parent entropy (9 yes, 5 no)      {parent:.4f}")
    print(f"  weighted child entropy            {weighted:.4f}")
    print(f"  information gain                  {gain:.4f}")
    print("       0.2467 is exactly Course 8's Outlook gain. A PURE node has")
    print("       entropy 0, which is what the tree is driving toward")


def depth_controls_overfitting():
    """unit-2.md 2.3's table, reproduced exactly."""
    rows = []
    for depth in (1, 2, 3, 5, 10, None):
        tree = DecisionTreeClassifier(max_depth=depth,
                                      random_state=RANDOM_STATE).fit(XTR, YTR)
        rows.append((depth, tree.score(XTR, YTR), tree.score(XTE, YTE),
                     tree.get_depth(), tree.get_n_leaves()))

    by_depth = {d: (tr, te) for d, tr, te, _, _ in rows}
    assert round(by_depth[1][0], 4) == 0.9533 and round(by_depth[1][1], 4) == 0.9200
    assert round(by_depth[3][0], 4) == 0.9800 and round(by_depth[3][1], 4) == 0.9400
    assert round(by_depth[5][0], 4) == 0.9967 and round(by_depth[5][1], 4) == 0.9500
    assert by_depth[10][0] == 1.0 and by_depth[None][0] == 1.0
    assert round(by_depth[10][1], 4) == 0.9400
    assert by_depth[None][1] < by_depth[5][1], \
        "the unrestricted tree generalises WORSE than the depth-5 tree"

    print(f"    {'max_depth':>10} {'train':>8} {'test':>8} {'gap':>8} "
          f"{'depth':>6} {'leaves':>7}")
    for depth, tr, te, actual, leaves in rows:
        mark = "  <- best test" if te == max(r[2] for r in rows) else ""
        print(f"    {str(depth):>10} {tr:8.4f} {te:8.4f} {tr - te:+8.4f} "
              f"{actual:6d} {leaves:7d}{mark}")
    print("       training accuracy reaches a PERFECT 1.0000 and test accuracy")
    print("       FALLS. The tree memorised the training set including its")
    print("       noise. A training accuracy of 1.0 is a warning, not a result")


def a_shallow_tree_is_readable():
    """The reason to use trees at all: you can read the rules."""
    tree = DecisionTreeClassifier(max_depth=2,
                                  random_state=RANDOM_STATE).fit(XTR, YTR)
    rules = export_text(tree, feature_names=FEATURES)

    assert "support_calls" in rules
    assert rules.count("class:") >= 3
    assert tree.get_n_leaves() <= 4

    print("  a depth-2 tree, as rules:")
    for line in rules.strip().splitlines():
        print(f"    {line}")
    print("       this is why a shallow tree is chosen when a decision must be")
    print("       EXPLAINED. No other model in the course reads like this")


def feature_importance_and_the_noise_column():
    """The irrelevant feature should score near zero -- and mostly does."""
    tree = DecisionTreeClassifier(max_depth=3,
                                  random_state=RANDOM_STATE).fit(XTR, YTR)
    importance = dict(zip(FEATURES, tree.feature_importances_))

    assert round(sum(importance.values()), 10) == 1.0, "importances sum to 1"
    assert importance["support_calls"] > importance["irrelevant"]
    assert importance["support_calls"] > 0.5, importance

    print("  feature importances (depth-3 tree):")
    for name, value in sorted(importance.items(), key=lambda kv: -kv[1]):
        print(f"    {name:16s} {value:.4f}  {'#' * int(value * 40)}")
    print("       the two real features dominate. Impurity importance is biased")
    print("       toward continuous and high-cardinality features, so prefer")
    print("       PERMUTATION importance when the ranking matters")


def cross_validation_beats_one_split():
    """unit-2.md 2.2: a single split could report anything in a wide range."""
    cv = StratifiedKFold(5, shuffle=True, random_state=RANDOM_STATE)
    scores = cross_val_score(
        DecisionTreeClassifier(max_depth=3, random_state=RANDOM_STATE),
        X, Y, cv=cv)

    assert len(scores) == 5
    assert [round(s, 4) for s in scores] == [0.975, 0.9625, 0.925, 0.9, 0.95]
    assert round(scores.mean(), 4) == 0.9425
    assert round(scores.std(), 4) == 0.0269
    assert scores.max() - scores.min() > 0.07

    print(f"  5-fold CV, depth 3: {[round(s, 4) for s in scores]}")
    print(f"    mean {scores.mean():.4f}   sd {scores.std():.4f}   "
          f"range {scores.min():.4f} to {scores.max():.4f}")
    print("       a single train/test split could have reported anything from")
    print("       0.90 to 0.975. That spread is why one split is not evidence")
    print("       and why you quote mean +/- sd")


def iris_tree():
    """The other standard dataset, for comparison with experiment 11."""
    _, data = iris_frame()
    Xi, yi = data.data.to_numpy(), data.target
    xtr, xte, ytr, yte = train_test_split(
        Xi, yi, test_size=0.3, random_state=RANDOM_STATE, stratify=yi)
    tree = DecisionTreeClassifier(max_depth=3,
                                  random_state=RANDOM_STATE).fit(xtr, ytr)

    assert round(tree.score(xte, yte), 4) == 0.9778, tree.score(xte, yte)
    assert tree.get_n_leaves() <= 5

    print(f"  iris, depth-3 tree: test accuracy {tree.score(xte, yte):.4f} "
          f"with {tree.get_n_leaves()} leaves")
    print("       three species separated by at most three questions")


def main():
    print("Experiment 8 -- Decision tree classification")
    # Step 1: Compute entropy and gain by hand
    entropy_and_gain_by_hand()
    # Step 2: Vary max_depth
    print("  max_depth against overfitting:")
    depth_controls_overfitting()
    # Step 3: Print a shallow tree
    a_shallow_tree_is_readable()
    # Step 4: Read the feature importances
    feature_importance_and_the_noise_column()
    # Step 5: Cross-validate
    cross_validation_beats_one_split()
    # Step 6: Grow a tree on iris
    iris_tree()


if __name__ == "__main__":
    main()

5. Execution and Results

OUTPUT

Experiment 8 -- Decision tree classification
  parent entropy (9 yes, 5 no)      0.9403
  weighted child entropy            0.6935
  information gain                  0.2467
       0.2467 is exactly Course 8's Outlook gain. A PURE node has
       entropy 0, which is what the tree is driving toward
  max_depth against overfitting:
     max_depth    train     test      gap  depth  leaves
             1   0.9533   0.9200  +0.0333      1       2
             2   0.9633   0.9300  +0.0333      2       4
             3   0.9800   0.9400  +0.0400      3       5
             5   0.9967   0.9500  +0.0467      5      10  <- best test
            10   1.0000   0.9400  +0.0600      6      11
          None   1.0000   0.9400  +0.0600      6      11
       training accuracy reaches a PERFECT 1.0000 and test accuracy
       FALLS. The tree memorised the training set including its
       noise. A training accuracy of 1.0 is a warning, not a result
  a depth-2 tree, as rules:
    |--- support_calls <= 5.15
    |   |--- tenure_months <= 12.91
    |   |   |--- class: 0
    |   |--- tenure_months >  12.91
    |   |   |--- class: 0
    |--- support_calls >  5.15
    |   |--- tenure_months <= 13.50
    |   |   |--- class: 1
    |   |--- tenure_months >  13.50
    |   |   |--- class: 0
       this is why a shallow tree is chosen when a decision must be
       EXPLAINED. No other model in the course reads like this
  feature importances (depth-3 tree):
    support_calls    0.8581  ##################################
    tenure_months    0.1419  #####
    irrelevant       0.0000
       the two real features dominate. Impurity importance is biased
       toward continuous and high-cardinality features, so prefer
       PERMUTATION importance when the ranking matters
  5-fold CV, depth 3: [np.float64(0.975), np.float64(0.9625), np.float64(0.925), np.float64(0.9), np.float64(0.95)]
    mean 0.9425   sd 0.0269   range 0.9000 to 0.9750
       a single train/test split could have reported anything from
       0.90 to 0.975. That spread is why one split is not evidence
       and why you quote mean +/- sd
  iris, depth-3 tree: test accuracy 0.9778 with 5 leaves
       three species separated by at most three questions

The noise feature gets importance exactly 0.0000, and 5-fold CV gives [0.975, 0.9625, 0.925, 0.9, 0.95] — mean 0.9425, sd 0.0269. A single split could have reported anything from 0.90 to 0.975.

RESULT

Gain 0.2467, as in Data Mining; training accuracy reaches 1.0000 while test accuracy falls; the noise column scores importance 0.0000.

Experiment 9 — Naive Bayes classification

1. Question

Classify with Naive Bayes.

2. Aim

Reproduce Data Mining's posteriors, then see the zero-frequency problem and the false independence assumption.

3. Steps

  1. Compute the posteriors by hand.
  2. See one zero destroy the posterior, and smooth it.
  3. Measure how false independence is.
  4. Compare Gaussian with multinomial.
  5. Classify text.

THE RESULTS

Data Mining's posteriors reproduced — 0.005291 for Yes and 0.020571 for No, normalising to 79.54% / 20.46% — then the two ideas that matter:

Plus MultinomialNB on a toy spam corpus, and the note that scikit-learn's alpha=1.0 is Laplace smoothing, on by default.

4. Programme

"""Experiment 9 — Naive Bayes classification.

Two things unit-4.md section 4.3 claims and this script proves:

  * a single zero probability destroys the whole posterior, because the
    likelihood is a PRODUCT -- and Laplace smoothing fixes it
  * the independence assumption is measurably FALSE on real data, and Naive
    Bayes classifies well anyway
"""
import numpy as np
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.metrics import accuracy_score
from sklearn.model_selection import cross_val_score, train_test_split
from sklearn.naive_bayes import GaussianNB, MultinomialNB

from fixtures import RANDOM_STATE, iris_frame

_, DATA = iris_frame()
X, Y = DATA.data.to_numpy(), DATA.target


def bayes_by_hand():
    """The posterior computed from counts, exactly as Course 8 did."""
    # 14 days: 9 Play=Yes, 5 Play=No. Predict for (Sunny, Cool, High, Strong).
    prior = {"Yes": 9 / 14, "No": 5 / 14}
    likelihood = {
        "Yes": {"Sunny": 2 / 9, "Cool": 3 / 9, "High": 3 / 9, "Strong": 3 / 9},
        "No":  {"Sunny": 3 / 5, "Cool": 1 / 5, "High": 4 / 5, "Strong": 3 / 5},
    }

    posterior = {}
    for label in ("Yes", "No"):
        product = prior[label]
        for value in likelihood[label].values():
            product *= value
        posterior[label] = product

    assert round(posterior["Yes"], 6) == 0.005291, round(posterior["Yes"], 6)
    assert round(posterior["No"], 6) == 0.020571, round(posterior["No"], 6)
    assert posterior["No"] > posterior["Yes"]

    total = sum(posterior.values())
    normalised = {k: v / total for k, v in posterior.items()}
    # 0.020571 / (0.005291 + 0.020571) = 0.020571 / 0.025862 = 0.79541
    assert round(normalised["No"], 4) == 0.7954, round(normalised["No"], 4)
    assert round(sum(normalised.values()), 10) == 1.0

    print(f"  P(Yes) x likelihoods = {posterior['Yes']:.6f}")
    print(f"  P(No)  x likelihoods = {posterior['No']:.6f}   <- larger")
    print(f"  normalised: No {normalised['No'] * 100:.2f}%, "
          f"Yes {normalised['Yes'] * 100:.2f}%")
    print("       these are exactly Course 8's figures. The evidence P(X) is")
    print("       the same for both classes, so it can be dropped for the")
    print("       COMPARISON and restored only to normalise")


def one_zero_destroys_the_posterior():
    """The zero-frequency problem, and why it is fatal rather than merely bad."""
    prior = 9 / 14
    likelihoods = [2 / 9, 3 / 9, 3 / 9, 3 / 9]

    healthy = prior * np.prod(likelihoods)
    with_zero = prior * np.prod(likelihoods[:3] + [0.0])

    assert round(healthy, 6) == 0.005291
    assert with_zero == 0.0, "ONE zero drives the entire product to zero"

    # Laplace smoothing: (count + 1) / (total + number of distinct values).
    smoothed = prior * np.prod(likelihoods[:3] + [(0 + 1) / (9 + 2)])
    assert smoothed > 0.0
    # (9/14)(2/9)(3/9)(3/9)(1/11) = 0.015873 x 0.090909 = 0.001443
    assert round(smoothed, 6) == 0.001443, round(smoothed, 6)

    print(f"  all four features observed      posterior {healthy:.6f}")
    print(f"  ONE feature never seen with Yes posterior {with_zero:.6f}   <- zero")
    print(f"  with Laplace smoothing (+1)     posterior {smoothed:.6f}")
    print("       the likelihood is a PRODUCT, so a single unseen feature value")
    print("       vetoes the class no matter how strongly the other three")
    print("       support it. Laplace smoothing replaces the 0 with 1/(n+k)")


def the_independence_assumption_is_false():
    """Measure the correlation Naive Bayes assumes away."""
    corr = DATA.data.corr().to_numpy()
    off_diagonal = np.abs(corr[np.triu_indices(4, k=1)])

    assert len(off_diagonal) == 6
    assert off_diagonal.max() > 0.96, off_diagonal.max()
    assert (off_diagonal > 0.8).sum() >= 3

    # And yet it classifies well.
    scores = cross_val_score(GaussianNB(), X, Y, cv=5)
    assert round(scores.mean(), 4) == 0.9533, round(scores.mean(), 4)

    print(f"  iris feature correlations (off-diagonal): "
          f"{np.round(np.sort(off_diagonal)[::-1], 4)}")
    print(f"  three pairs correlate above 0.8, one above 0.96 --")
    print(f"  the conditional independence assumption is plainly FALSE")
    print(f"  GaussianNB 5-fold CV accuracy anyway: {scores.mean():.4f}")
    print("       that is the interesting part. Classification needs only the")
    print("       CORRECT CLASS TO SCORE HIGHEST, not the probabilities to be")
    print("       right -- so a badly wrong assumption still ranks correctly")


def gaussian_versus_multinomial():
    """Pick the variant that matches the feature type."""
    xtr, xte, ytr, yte = train_test_split(
        X, Y, test_size=0.3, random_state=RANDOM_STATE, stratify=Y)

    gaussian = GaussianNB().fit(xtr, ytr).score(xte, yte)
    assert round(gaussian, 4) == 0.9111, gaussian

    # MultinomialNB is for COUNTS. Text is its home ground.
    corpus = ["win money now", "cheap pills cheap", "meeting at noon",
              "project deadline tomorrow", "win a free prize now",
              "lunch meeting tomorrow", "free money free money",
              "please review the project"]
    labels = [1, 1, 0, 0, 1, 0, 1, 0]

    vec = CountVectorizer()
    counts = vec.fit_transform(corpus)
    text_model = MultinomialNB().fit(counts, labels)
    predicted = text_model.predict(vec.transform(["free money now",
                                                  "project meeting tomorrow"]))

    assert counts.shape[0] == 8
    assert list(predicted) == [1, 0], list(predicted)
    assert round(text_model.alpha, 4) == 1.0, "alpha=1 IS Laplace smoothing"

    print(f"  GaussianNB on iris (continuous features): {gaussian:.4f}")
    print(f"  MultinomialNB on {counts.shape[0]} short messages, "
          f"{counts.shape[1]} distinct words:")
    print(f"    'free money now'          -> {'SPAM' if predicted[0] else 'ham'}")
    print(f"    'project meeting tomorrow'-> {'SPAM' if predicted[1] else 'ham'}")
    print(f"    scikit-learn's alpha defaults to {text_model.alpha} -- that IS")
    print("       Laplace smoothing, on by default, because a word never seen")
    print("       with a class is otherwise guaranteed in any real vocabulary")


def why_text():
    """Why Naive Bayes owns spam filtering: the shape of the problem."""
    corpus = ["win money now"] * 50 + ["meeting at noon"] * 50
    vec = CountVectorizer()
    counts = vec.fit_transform(corpus)

    n, p = counts.shape
    density = counts.nnz / (n * p)

    assert density < 0.6, density
    assert p < n, "a toy corpus; real vocabularies are far LARGER than n"

    print(f"  a toy corpus: {n} documents, {p} distinct words, "
          f"{density * 100:.0f}% of the matrix non-zero")
    print("       real text has tens of thousands of features and a matrix")
    print("       that is >99% zeros. Naive Bayes is LINEAR in the number of")
    print("       features, trains in one pass, needs little data and updates")
    print("       incrementally as users mark mail -- which is exactly the")
    print("       shape of the spam problem")


def main():
    print("Experiment 9 -- Naive Bayes classification")
    # Step 1: Compute the posteriors by hand
    bayes_by_hand()
    # Step 2: See one zero destroy the posterior, and smooth it
    one_zero_destroys_the_posterior()
    # Step 3: Measure how false independence is
    the_independence_assumption_is_false()
    # Step 4: Compare Gaussian with multinomial
    gaussian_versus_multinomial()
    # Step 5: Classify text
    why_text()


if __name__ == "__main__":
    main()

5. Execution and Results

OUTPUT

Experiment 9 -- Naive Bayes classification
  P(Yes) x likelihoods = 0.005291
  P(No)  x likelihoods = 0.020571   <- larger
  normalised: No 79.54%, Yes 20.46%
       these are exactly Course 8's figures. The evidence P(X) is
       the same for both classes, so it can be dropped for the
       COMPARISON and restored only to normalise
  all four features observed      posterior 0.005291
  ONE feature never seen with Yes posterior 0.000000   <- zero
  with Laplace smoothing (+1)     posterior 0.001443
       the likelihood is a PRODUCT, so a single unseen feature value
       vetoes the class no matter how strongly the other three
       support it. Laplace smoothing replaces the 0 with 1/(n+k)
  iris feature correlations (off-diagonal): [0.9629 0.8718 0.8179 0.4284 0.3661 0.1176]
  three pairs correlate above 0.8, one above 0.96 --
  the conditional independence assumption is plainly FALSE
  GaussianNB 5-fold CV accuracy anyway: 0.9533
       that is the interesting part. Classification needs only the
       CORRECT CLASS TO SCORE HIGHEST, not the probabilities to be
       right -- so a badly wrong assumption still ranks correctly
  GaussianNB on iris (continuous features): 0.9111
  MultinomialNB on 8 short messages, 17 distinct words:
    'free money now'          -> SPAM
    'project meeting tomorrow'-> ham
    scikit-learn's alpha defaults to 1.0 -- that IS
       Laplace smoothing, on by default, because a word never seen
       with a class is otherwise guaranteed in any real vocabulary
  a toy corpus: 100 documents, 6 distinct words, 50% of the matrix non-zero
       real text has tens of thousands of features and a matrix
       that is >99% zeros. Naive Bayes is LINEAR in the number of
       features, trains in one pass, needs little data and updates
       incrementally as users mark mail -- which is exactly the
       shape of the spam problem

RESULT

Posteriors 0.005291 and 0.020571, as in Data Mining; one zero sends a posterior to 0, and Laplace smoothing restores it to 0.001443.

Experiment 10 — K-Means clustering

1. Question

Cluster the iris data with K-Means, and choose k.

2. Aim

Choose k by the elbow and the silhouette, and compare the clusters with the species.

3. Steps

  1. Find the elbow.
  2. Compare the silhouette with the truth.
  3. See where the errors fall.
  4. Vary the initialisation.
  5. Add an outlier.

THE ELBOW

k WCSS drop silhouette
1 600.0000 — —
2 222.3617 377.64 0.5818
3 139.8205 82.54 0.4599
4 114.0925 25.73 0.3869
6 81.5444 9.38 0.3171

WCSS at k=1 is exactly 600 = 150 × 4 — a free check that the data was standardised.

4. Programme

"""Experiment 10 — K-Means clustering.

Reproduces unit-5.md section 5.5, including the result worth remembering:
silhouette prefers k=2 on iris, where the truth is k=3.
"""
import numpy as np
from sklearn.cluster import KMeans
from sklearn.metrics import adjusted_rand_score, silhouette_score
from sklearn.preprocessing import StandardScaler

from fixtures import RANDOM_STATE, iris_frame

_, DATA = iris_frame()
X_RAW = DATA.data.to_numpy()
Y = DATA.target
Z = StandardScaler().fit_transform(X_RAW)


def the_elbow():
    """unit-5.md's WCSS table, including the k=1 arithmetic check."""
    rows = []
    for k in range(1, 7):
        km = KMeans(k, n_init=10, random_state=RANDOM_STATE).fit(Z)
        sil = silhouette_score(Z, km.labels_) if k > 1 else None
        rows.append((k, km.inertia_, sil))

    wcss = {k: w for k, w, _ in rows}
    sils = {k: s for k, _, s in rows if s is not None}

    assert round(wcss[1], 4) == 600.0, wcss[1]
    assert wcss[1] == len(Z) * Z.shape[1], "n x p, because each column has variance 1"
    assert round(wcss[2], 4) == 222.3617
    assert round(wcss[3], 4) == 139.8205
    assert round(wcss[6], 4) == 81.5444
    assert all(wcss[k] > wcss[k + 1] for k in range(1, 6)), \
        "WCSS falls monotonically -- which is why it alone cannot choose k"

    print(f"    {'k':>3} {'WCSS':>10} {'drop':>10} {'silhouette':>12}")
    for k, w, s in rows:
        drop = "" if k == 1 else f"{wcss[k - 1] - w:10.4f}"
        sil = "" if s is None else f"{s:12.4f}"
        print(f"    {k:3d} {w:10.4f} {drop:>10} {sil:>12}")
    print(f"       WCSS at k=1 is exactly {wcss[1]:.0f} = n x p = "
          f"{len(Z)} x {Z.shape[1]} -- a free check that the data really was")
    print("       standardised. The big drops are to k=2 and k=3, then it")
    print("       flattens: the elbow is at 2 or 3")
    return wcss, sils


def silhouette_disagrees_with_the_truth(sils):
    """The most instructive result in the unit."""
    km2 = KMeans(2, n_init=10, random_state=RANDOM_STATE).fit(Z)
    km3 = KMeans(3, n_init=10, random_state=RANDOM_STATE).fit(Z)

    ari2 = adjusted_rand_score(Y, km2.labels_)
    ari3 = adjusted_rand_score(Y, km3.labels_)

    assert round(sils[2], 4) == 0.5818
    assert round(sils[3], 4) == 0.4599
    assert sils[2] > sils[3], "silhouette PREFERS k=2"
    assert round(ari3, 4) == 0.6201, round(ari3, 4)
    assert ari3 > ari2, "yet k=3 agrees far better with the true species"
    assert len(set(Y)) == 3, "and there really are three species"

    print(f"    k=2: silhouette {sils[2]:.4f}   ARI vs species {ari2:.4f}")
    print(f"    k=3: silhouette {sils[3]:.4f}   ARI vs species {ari3:.4f}")
    print(f"    iris has {len(set(Y))} species")
    print("       SILHOUETTE SAYS 2. THE TRUTH IS 3. Nothing is broken: setosa")
    print("       is cleanly separate while versicolor and virginica overlap,")
    print("       so by a purely geometric measure two groups ARE tidier.")
    print("       An internal metric measures tidiness, not correctness --")
    print("       never choose k from silhouette alone")


def where_the_errors_fall():
    """Which species K-Means confuses, and why that is the expected answer."""
    km = KMeans(3, n_init=10, random_state=RANDOM_STATE).fit(Z)
    names = list(DATA.target_names)

    table = np.zeros((3, 3), dtype=int)
    for true, cluster in zip(Y, km.labels_):
        table[true, cluster] += 1

    # Setosa lands entirely in one cluster; the other two bleed into each other.
    setosa_row = table[names.index("setosa")]
    assert setosa_row.max() == 50 and setosa_row.sum() == 50, \
        "all 50 setosa in ONE cluster"
    versicolor = table[names.index("versicolor")]
    virginica = table[names.index("virginica")]
    assert (versicolor > 0).sum() >= 2 or (virginica > 0).sum() >= 2, \
        "at least one of the other two is split"

    print("    species      cluster0 cluster1 cluster2")
    for i, name in enumerate(names):
        print(f"    {name:12s} {table[i, 0]:8d} {table[i, 1]:8d} {table[i, 2]:8d}")
    print("       setosa is captured perfectly; versicolor and virginica are")
    print("       the pair that overlaps. That is exactly why silhouette")
    print("       prefers two clusters, and it is a property of the FLOWERS")


def initialisation_matters():
    """K-Means finds a LOCAL minimum. n_init exists for this reason."""
    single = KMeans(3, n_init=1, init="random",
                    random_state=7).fit(Z).inertia_
    many = KMeans(3, n_init=20, init="random",
                  random_state=7).fit(Z).inertia_
    plus = KMeans(3, n_init=10, init="k-means++",
                  random_state=RANDOM_STATE).fit(Z).inertia_

    assert many <= single, (many, single)
    assert round(plus, 4) == 139.8205

    print(f"    random init, n_init=1   WCSS {single:.4f}")
    print(f"    random init, n_init=20  WCSS {many:.4f}")
    print(f"    k-means++,   n_init=10  WCSS {plus:.4f}")
    print("       the objective never increases within a run, so K-Means always")
    print("       converges -- to a LOCAL minimum. Restarts and k-means++ are")
    print("       how that is managed, and both are scikit-learn defaults now")


def outliers_drag_the_centroid():
    """Weakness 4: the mean is not robust."""
    base = np.array([[1.0, 1.0], [1.2, 0.9], [0.9, 1.1], [1.1, 1.0]])
    with_outlier = np.vstack([base, [[50.0, 50.0]]])

    clean_centre = base.mean(axis=0)
    dragged = with_outlier.mean(axis=0)

    assert np.allclose(clean_centre, [1.05, 1.0])
    assert dragged[0] > 10, dragged
    # A medoid -- an actual data point -- is unmoved.
    medoid = base[np.argmin([np.abs(base - p).sum() for p in base])]
    assert np.allclose(medoid, [1.0, 1.0]) or np.allclose(medoid, [1.1, 1.0])

    print(f"    4 tight points, centroid {np.round(clean_centre, 4)}")
    print(f"    add ONE point at (50, 50): centroid {np.round(dragged, 4)}")
    print(f"    the medoid (a real data point) stays at {np.round(medoid, 4)}")
    print("       one outlier moved the centre by ~10 units. k-Medoids uses an")
    print("       actual data point and is unmoved -- that is its main")
    print("       advantage, along with accepting any distance metric")


def main():
    print("Experiment 10 -- K-Means clustering")
    # Step 1: Find the elbow
    print("  the elbow method on standardised iris:")
    wcss, sils = the_elbow()
    # Step 2: Compare the silhouette with the truth
    print("  silhouette against the ground truth:")
    silhouette_disagrees_with_the_truth(sils)
    # Step 3: See where the errors fall
    print("  where the errors fall:")
    where_the_errors_fall()
    # Step 4: Vary the initialisation
    print("  initialisation:")
    initialisation_matters()
    # Step 5: Add an outlier
    print("  sensitivity to outliers:")
    outliers_drag_the_centroid()


if __name__ == "__main__":
    main()

5. Execution and Results

OUTPUT

Experiment 10 -- K-Means clustering
  the elbow method on standardised iris:
      k       WCSS       drop   silhouette
      1   600.0000
      2   222.3617   377.6383       0.5818
      3   139.8205    82.5412       0.4599
      4   114.0925    25.7279       0.3869
      5    90.9275    23.1650       0.3459
      6    81.5444     9.3831       0.3171
       WCSS at k=1 is exactly 600 = n x p = 150 x 4 -- a free check that the data really was
       standardised. The big drops are to k=2 and k=3, then it
       flattens: the elbow is at 2 or 3
  silhouette against the ground truth:
    k=2: silhouette 0.5818   ARI vs species 0.5681
    k=3: silhouette 0.4599   ARI vs species 0.6201
    iris has 3 species
       SILHOUETTE SAYS 2. THE TRUTH IS 3. Nothing is broken: setosa
       is cleanly separate while versicolor and virginica overlap,
       so by a purely geometric measure two groups ARE tidier.
       An internal metric measures tidiness, not correctness --
       never choose k from silhouette alone
  where the errors fall:
    species      cluster0 cluster1 cluster2
    setosa              0       50        0
    versicolor         39        0       11
    virginica          14        0       36
       setosa is captured perfectly; versicolor and virginica are
       the pair that overlaps. That is exactly why silhouette
       prefers two clusters, and it is a property of the FLOWERS
  initialisation:
    random init, n_init=1   WCSS 140.0820
    random init, n_init=20  WCSS 139.8205
    k-means++,   n_init=10  WCSS 139.8205
       the objective never increases within a run, so K-Means always
       converges -- to a LOCAL minimum. Restarts and k-means++ are
       how that is managed, and both are scikit-learn defaults now
  sensitivity to outliers:
    4 tight points, centroid [1.05 1.  ]
    add ONE point at (50, 50): centroid [10.84 10.8 ]
    the medoid (a real data point) stays at [1. 1.]
       one outlier moved the centre by ~10 units. k-Medoids uses an
       actual data point and is unmoved -- that is its main
       advantage, along with accepting any distance metric

THE MOST INSTRUCTIVE RESULT IN THE COURSE

Silhouette prefers k = 2 (0.5818). Iris has three species. At k=3 the ARI against the truth is 0.6201, better than k=2's 0.5681 — the geometric metric and the ground truth disagree.

The confusion table shows why: all 50 setosa land in one cluster, while versicolor (39/11) and virginica (14/36) bleed into each other. It is a property of the flowers, not a bug.

RESULT

The silhouette prefers k = 2; iris has three species, and at k = 3 the ARI is 0.6201.

Experiment 11 — k-Nearest Neighbour classification

1. Question

Classify with k-Nearest Neighbours.

2. Aim

See when scaling matters and when it does not, and choose k.

3. Steps

  1. Scale features whose units differ.
  2. Scale features whose units do not.
  3. Choose k.
  4. Compare distance metrics.
  5. Time the lazy learner.

THE RESULTS

Scaling, when units genuinely differ — age in years against income in rupees, where the true rule depends on age alone:

Test accuracy
Unscaled 0.5500
Scaled 0.9750

Income's standard deviation is 42,669× age's.

Scaling, when they do not — iris, all four features in centimetres with a spread ratio of only 4.05×: unscaled 0.9778, scaled 0.9111. Scaling made it slightly worse. The rule is about differing units, not ritual.

Choosing k: k=1 scores 1.0000 on training by construction; at k = n_train = 105 every query sees the same 105 votes and returns one fixed class — 0.3333 on three balanced species.

4. Programme

"""Experiment 11 — k-Nearest Neighbour classification.

unit-4.md section 4.4 makes two claims that look contradictory until you read
them carefully, and both are measured here:

  * scaling can be the difference between 0.5500 and 0.9750
  * on iris it barely matters, and here it slightly HURT

The rule is about differing units, not ritual.
"""
import numpy as np
from sklearn.model_selection import cross_val_score, train_test_split
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

from fixtures import RANDOM_STATE, iris_frame

_, DATA = iris_frame()
X, Y = DATA.data.to_numpy(), DATA.target
XTR, XTE, YTR, YTE = train_test_split(
    X, Y, test_size=0.3, random_state=RANDOM_STATE, stratify=Y)


def scaling_when_units_differ():
    """The case the rule exists for: age in years, income in rupees."""
    rng = np.random.default_rng(0)
    n = 400
    age = rng.uniform(20, 60, n)
    income = rng.uniform(200000, 2000000, n)
    y = (age > 40).astype(int)              # the truth depends on AGE ALONE
    Xd = np.c_[age, income]

    xtr, xte, ytr, yte = train_test_split(
        Xd, y, test_size=0.3, random_state=RANDOM_STATE, stratify=y)

    unscaled = KNeighborsClassifier(5).fit(xtr, ytr).score(xte, yte)
    scaled = make_pipeline(StandardScaler(),
                           KNeighborsClassifier(5)).fit(xtr, ytr).score(xte, yte)

    ratio = Xd[:, 1].std() / Xd[:, 0].std()

    assert round(unscaled, 4) == 0.5500, unscaled
    assert round(scaled, 4) == 0.9750, scaled
    assert ratio > 40000, ratio
    assert scaled - unscaled > 0.40

    print(f"  standard deviations: age {Xd[:, 0].std():.2f} years, "
          f"income {Xd[:, 1].std():,.0f} rupees  ->  ratio {ratio:,.0f}x")
    print(f"    UNSCALED k-NN (k=5): {unscaled:.4f}   <- barely better than a coin")
    print(f"    SCALED   k-NN (k=5): {scaled:.4f}")
    print("       Euclidean distance is essentially income alone, and income is")
    print("       IRRELEVANT to the true rule. The classifier is not weak; it")
    print("       is being asked the wrong question")


def scaling_when_units_do_not_differ():
    """Iris, where all four features are centimetres. The honest nuance."""
    spreads = X.std(axis=0)
    ratio = spreads.max() / spreads.min()

    unscaled = KNeighborsClassifier(5).fit(XTR, YTR).score(XTE, YTE)
    scaled = make_pipeline(StandardScaler(),
                           KNeighborsClassifier(5)).fit(XTR, YTR).score(XTE, YTE)

    assert round(ratio, 2) == 4.05, round(ratio, 2)
    assert round(unscaled, 4) == 0.9778, unscaled
    assert round(scaled, 4) == 0.9111, scaled
    assert unscaled > scaled, "scaling made it WORSE here"

    print(f"  iris feature sds (all cm): {np.round(spreads, 3)}  "
          f"ratio {ratio:.2f}x")
    print(f"    UNSCALED k-NN (k=5): {unscaled:.4f}")
    print(f"    SCALED   k-NN (k=5): {scaled:.4f}   <- slightly WORSE")
    print("       a 4x spread is not an imbalance worth correcting, and on a")
    print("       45-row test set a 3-point difference is noise. SCALE WHEN")
    print("       UNITS DIFFER -- iris is the case where they do not")


def choosing_k():
    """Small k = high variance; large k = high bias; k=n = the majority class."""
    rows = []
    for k in (1, 3, 5, 11, 25, 51, len(YTR)):
        model = make_pipeline(StandardScaler(), KNeighborsClassifier(k))
        model.fit(XTR, YTR)
        train = model.score(XTR, YTR)
        test = model.score(XTE, YTE)
        cv = cross_val_score(
            make_pipeline(StandardScaler(), KNeighborsClassifier(k)),
            X, Y, cv=5).mean()
        rows.append((k, train, test, cv))

    by_k = {k: (tr, te, cv) for k, tr, te, cv in rows}

    n_train = len(YTR)
    assert by_k[1][0] == 1.0, "k=1 always gets its own training points right"
    # k = n_train votes over EVERY training point, so it returns one fixed
    # class -- 1/3 on three balanced species, on both train and test.
    assert round(by_k[n_train][0], 4) == 0.3333, by_k[n_train]
    assert round(by_k[n_train][1], 4) == 0.3333, by_k[n_train]
    # Its CV score is higher (0.6667) only because each CV fold trains on 120
    # rows, so k=105 is NOT all of them there. Worth noticing, not hiding.
    assert round(by_k[n_train][2], 4) == 0.6667, by_k[n_train]
    assert by_k[5][2] > by_k[n_train][2]
    assert by_k[51][1] < by_k[11][1], "large k over-smooths"

    print(f"    {'k':>5} {'train':>8} {'test':>8} {'5-fold CV':>10}")
    for k, tr, te, cv in rows:
        note = ""
        if k == 1:
            note = "  <- perfect on train, by construction"
        elif k == len(YTR):
            note = "  <- k = n_train: one fixed class"
        print(f"    {k:5d} {tr:8.4f} {te:8.4f} {cv:10.4f}{note}")
    print(f"       at k = n_train ({len(YTR)}) every query sees the same {len(YTR)} votes,")
    print("       so it returns ONE class: 0.3333 on three balanced species.")
    print("       Its CV column reads 0.6667 only because each CV fold trains")
    print("       on 120 rows, so k=105 is not all of them there.")
    print("       k=1 scores 1.0000 on training because every point is its own")
    print("       nearest neighbour -- that is not learning either. k is a")
    print("       HYPERPARAMETER: choose it on validation folds, and use an odd")
    print("       k for binary problems to avoid ties")


def distance_metrics():
    """Euclidean, Manhattan and Chebyshev, on the same split."""
    results = {}
    for metric in ("euclidean", "manhattan", "chebyshev"):
        model = make_pipeline(StandardScaler(),
                              KNeighborsClassifier(5, metric=metric))
        results[metric] = cross_val_score(model, X, Y, cv=5).mean()

    assert all(0.85 < v < 1.0 for v in results.values()), results
    assert max(results.values()) - min(results.values()) < 0.05, \
        "on well-scaled low-dimensional data the metric barely matters"

    print("    metric        5-fold CV")
    for metric, score in results.items():
        print(f"    {metric:12s} {score:10.4f}")
    print("       nearly identical here. The metric matters in HIGH dimensions,")
    print("       and for text -- where cosine distance ignores document length")
    print("       and Euclidean does not")


def lazy_learning_has_a_cost():
    """Training is instant; prediction is O(n) per query."""
    import time

    model = KNeighborsClassifier(5)
    t0 = time.perf_counter()
    model.fit(XTR, YTR)
    fit_time = time.perf_counter() - t0

    t0 = time.perf_counter()
    for _ in range(20):
        model.predict(XTE)
    predict_time = (time.perf_counter() - t0) / 20

    assert fit_time >= 0 and predict_time >= 0
    assert model.n_samples_fit_ == len(XTR), "it stored every training row"

    print(f"    fit stored {model.n_samples_fit_} rows in {fit_time * 1000:.3f} ms")
    print(f"    one predict over {len(XTE)} rows: {predict_time * 1000:.3f} ms")
    print("       a LAZY learner: fitting just stores, and all the work happens")
    print("       at prediction time. That is the opposite of every other model")
    print("       in this course, and it is why k-NN scales badly to large n")


def main():
    print("Experiment 11 -- k-Nearest Neighbour classification")
    # Step 1: Scale features whose units differ
    print("  scaling, when the units genuinely differ:")
    scaling_when_units_differ()
    # Step 2: Scale features whose units do not
    print("  scaling, when they do not:")
    scaling_when_units_do_not_differ()
    # Step 3: Choose k
    print("  choosing k:")
    choosing_k()
    # Step 4: Compare distance metrics
    print("  distance metrics:")
    distance_metrics()
    # Step 5: Time the lazy learner
    print("  the cost of lazy learning:")
    lazy_learning_has_a_cost()


if __name__ == "__main__":
    main()

5. Execution and Results

OUTPUT

Experiment 11 -- k-Nearest Neighbour classification
  scaling, when the units genuinely differ:
  standard deviations: age 11.79 years, income 503,214 rupees  ->  ratio 42,669x
    UNSCALED k-NN (k=5): 0.5500   <- barely better than a coin
    SCALED   k-NN (k=5): 0.9750
       Euclidean distance is essentially income alone, and income is
       IRRELEVANT to the true rule. The classifier is not weak; it
       is being asked the wrong question
  scaling, when they do not:
  iris feature sds (all cm): [0.825 0.434 1.759 0.76 ]  ratio 4.05x
    UNSCALED k-NN (k=5): 0.9778
    SCALED   k-NN (k=5): 0.9111   <- slightly WORSE
       a 4x spread is not an imbalance worth correcting, and on a
       45-row test set a 3-point difference is noise. SCALE WHEN
       UNITS DIFFER -- iris is the case where they do not
  choosing k:
        k    train     test  5-fold CV
        1   1.0000   0.9333     0.9467  <- perfect on train, by construction
        3   0.9714   0.9111     0.9533
        5   0.9810   0.9111     0.9600
       11   0.9714   0.9556     0.9467
       25   0.9143   0.8889     0.9400
       51   0.8762   0.8222     0.8600
      105   0.3333   0.3333     0.6667  <- k = n_train: one fixed class
       at k = n_train (105) every query sees the same 105 votes,
       so it returns ONE class: 0.3333 on three balanced species.
       Its CV column reads 0.6667 only because each CV fold trains
       on 120 rows, so k=105 is not all of them there.
       k=1 scores 1.0000 on training because every point is its own
       nearest neighbour -- that is not learning either. k is a
       HYPERPARAMETER: choose it on validation folds, and use an odd
       k for binary problems to avoid ties
  distance metrics:
    metric        5-fold CV
    euclidean        0.9600
    manhattan        0.9467
    chebyshev        0.9333
       nearly identical here. The metric matters in HIGH dimensions,
       and for text -- where cosine distance ignores document length
       and Euclidean does not
  the cost of lazy learning:
    fit stored 105 rows in 0.891 ms
    one predict over 45 rows: 0.998 ms
       a LAZY learner: fitting just stores, and all the work happens
       at prediction time. That is the opposite of every other model
       in this course, and it is why k-NN scales badly to large n

Step 5's two times measure this machine at one moment, so they differ from run to run; capture_lab_outputs.py --check sets those two lines aside and compares every other line exactly. What they show does not change: fitting only stores the rows, and the work is done at prediction.

RESULT

Scaling lifts accuracy from 0.5500 to 0.9750 when the units differ, and lowers it slightly on iris, where they do not.

Experiment 12 — DBSCAN

1. Question

Cluster with DBSCAN, and compare it with K-Means.

2. Aim

Cluster non-convex shapes, label core, border and noise points, choose eps, and find DBSCAN's own weakness.

3. Steps

  1. Cluster the crescents with DBSCAN and K-Means.
  2. Try every k.
  3. Label core, border and noise points.
  4. Choose eps from the k-distance plot.
  5. Cluster blobs of different density.
  6. Compare hierarchical linkage.

THE HEADLINE

The headline: two interleaved crescents, 300 points.

Algorithm ARI
K-Means (k=2) 0.2475
DBSCAN (eps 0.25, minPts 5) 1.0000

And it is not a tuning problem — the best ARI K-Means achieves over k = 2…10 is 0.2938. Its boundaries are straight perpendicular bisectors, so no k carves out a crescent.

4. Programme

"""Experiment 12 — DBSCAN.

The cleanest experiment in Unit 5: on two interleaved crescents DBSCAN scores
ARI 1.0000 and K-Means 0.2475. The reason is structural, not a matter of
tuning, and this script demonstrates that too.
"""
import numpy as np
from sklearn.cluster import DBSCAN, AgglomerativeClustering, KMeans
from sklearn.datasets import make_blobs, make_moons
from sklearn.metrics import adjusted_rand_score, silhouette_score
from sklearn.neighbors import NearestNeighbors
from sklearn.preprocessing import StandardScaler

from fixtures import RANDOM_STATE, iris_frame

XM, YM = make_moons(n_samples=300, noise=0.06, random_state=RANDOM_STATE)


def dbscan_beats_kmeans_on_non_convex_clusters():
    """unit-5.md 5.8's headline result."""
    km = KMeans(2, n_init=10, random_state=RANDOM_STATE).fit(XM)
    db = DBSCAN(eps=0.25, min_samples=5).fit(XM)

    ari_km = adjusted_rand_score(YM, km.labels_)
    ari_db = adjusted_rand_score(YM, db.labels_)
    clusters = len(set(db.labels_) - {-1})
    noise = int((db.labels_ == -1).sum())

    assert round(ari_km, 4) == 0.2475, round(ari_km, 4)
    assert round(ari_db, 4) == 1.0000, round(ari_db, 4)
    assert clusters == 2 and noise == 0

    print(f"  two interleaved crescents, 300 points:")
    print(f"    K-Means (k=2)  ARI {ari_km:.4f}")
    print(f"    DBSCAN         ARI {ari_db:.4f}   "
          f"{clusters} clusters, {noise} noise points")
    print("       DBSCAN recovers them PERFECTLY; K-Means is barely better")
    print("       than random")


def no_k_can_save_kmeans_here():
    """It is not a tuning problem. That is the point worth proving."""
    best = 0.0
    for k in range(2, 11):
        km = KMeans(k, n_init=10, random_state=RANDOM_STATE).fit(XM)
        best = max(best, adjusted_rand_score(YM, km.labels_))

    assert best < 0.55, best

    print(f"  the best ARI K-Means achieves over k = 2..10: {best:.4f}")
    print("       K-Means assigns each point to the NEAREST CENTROID, so its")
    print("       boundaries are straight perpendicular bisectors. A crescent")
    print("       cannot be carved out that way at ANY k. This is structural,")
    print("       not a tuning failure -- which is why the right answer is a")
    print("       different algorithm and not a better k")


def core_border_and_noise():
    """The three kinds of point, counted."""
    db = DBSCAN(eps=0.25, min_samples=5).fit(XM)

    core = np.zeros(len(XM), dtype=bool)
    core[db.core_sample_indices_] = True
    clustered = db.labels_ != -1
    border = clustered & ~core
    noise = ~clustered

    assert core.sum() + border.sum() + noise.sum() == len(XM)
    assert core.sum() > 0
    assert noise.sum() == 0, "this eps leaves no noise on clean crescents"

    # Tighten eps until noise appears -- that is what the parameter does.
    tight = DBSCAN(eps=0.10, min_samples=5).fit(XM)
    tight_noise = int((tight.labels_ == -1).sum())
    assert tight_noise > 0, "a smaller neighbourhood makes more points noise"

    print(f"  eps=0.25: core {core.sum()}, border {border.sum()}, "
          f"noise {noise.sum()}   (total {len(XM)})")
    print(f"  eps=0.10: noise rises to {tight_noise} points, "
          f"{len(set(tight.labels_) - {-1})} clusters")
    print("       a CORE point has >= min_samples neighbours within eps; a")
    print("       BORDER point is within eps of a core but is not core itself;")
    print("       everything else is NOISE and belongs to no cluster. K-Means")
    print("       has no such category -- it forces every point into a cluster")


def choosing_eps_with_a_k_distance_plot():
    """The standard heuristic, computed."""
    min_samples = 5
    neighbours = NearestNeighbors(n_neighbors=min_samples).fit(XM)
    distances, _ = neighbours.kneighbors(XM)
    kth = np.sort(distances[:, -1])

    # The elbow is where the sorted curve turns up sharply.
    knee = float(np.percentile(kth, 95))

    assert kth[0] <= kth[-1], "sorted ascending"
    assert 0.05 < knee < 0.35, knee

    working = DBSCAN(eps=knee, min_samples=min_samples).fit(XM)
    assert len(set(working.labels_) - {-1}) >= 1

    print(f"  k-distance plot with k = min_samples = {min_samples}:")
    print(f"    median {np.median(kth):.4f}, 95th percentile {knee:.4f}, "
          f"max {kth[-1]:.4f}")
    print(f"    eps = {knee:.4f} gives "
          f"{len(set(working.labels_) - {-1})} clusters, "
          f"{int((working.labels_ == -1).sum())} noise")
    print("       sort every point's distance to its k-th neighbour and take")
    print("       the ELBOW. Below it points are inside a dense region; above")
    print("       it they are not. minPts >= p+1, often 2p")


def dbscan_struggles_with_varying_density():
    """Its real weakness: one eps cannot serve two densities."""
    dense, _ = make_blobs(n_samples=200, centers=[[0, 0]], cluster_std=0.20,
                          random_state=RANDOM_STATE)
    sparse, _ = make_blobs(n_samples=60, centers=[[3, 3]], cluster_std=1.60,
                           random_state=RANDOM_STATE)
    X = np.vstack([dense, sparse])
    truth = np.r_[np.zeros(200, dtype=int), np.ones(60, dtype=int)]

    outcomes = {}
    for eps in (0.30, 0.80, 1.50, 2.50):
        db = DBSCAN(eps=eps, min_samples=5).fit(X)
        outcomes[eps] = (len(set(db.labels_) - {-1}),
                         int((db.labels_ == -1).sum()),
                         adjusted_rand_score(truth, db.labels_))

    km = KMeans(2, n_init=10, random_state=RANDOM_STATE).fit(X)
    ari_km = adjusted_rand_score(truth, km.labels_)

    # Small eps: the ENTIRE sparse blob is discarded as noise, leaving 1 cluster.
    assert outcomes[0.30][0] == 1 and outcomes[0.30][1] == 60
    # Large eps: they MERGE into one cluster and the partition is destroyed.
    assert outcomes[1.50][0] == 1 and round(outcomes[1.50][2], 4) == 0.0178
    assert outcomes[2.50][0] == 1 and round(outcomes[2.50][2], 4) == 0.0000
    # No eps tried recovers TWO clusters with little noise.
    assert not any(c == 2 and n < 10 for c, n, _ in outcomes.values()), \
        "no single eps separates them cleanly"
    assert round(ari_km, 4) == 0.8335, round(ari_km, 4)

    print("    a dense blob (sd 0.20, n=200) and a sparse one (sd 1.60, n=60),")
    print("    centres only 3 units apart:")
    print(f"    {'eps':>6} {'clusters':>9} {'noise':>7} {'ARI':>8}  outcome")
    labels = {0.30: "sparse blob DISCARDED as noise",
              0.80: "sparse blob fragmented",
              1.50: "the two MERGED",
              2.50: "the two MERGED"}
    for eps, (clusters, noise, ari) in outcomes.items():
        print(f"    {eps:6.2f} {clusters:9d} {noise:7d} {ari:8.4f}  {labels[eps]}")
    print(f"    K-Means (k=2) on the same data: ARI {ari_km:.4f}")
    print("       NO SINGLE eps recovers both: too small and the sparse cluster")
    print("       becomes 60 noise points, too large and the two merge. That is")
    print("       DBSCAN's real weakness, and OPTICS is the fix.")
    print("       Note the ARI of 1.0000 at eps=0.30 -- that is a METRIC")
    print("       ARTIFACT: all 60 sparse points got the single label -1, which")
    print("       ARI scores as a consistent group. A cluster of noise is not a")
    print("       cluster, and this is why you look at the counts as well.")
    print("       K-Means wins here because both blobs ARE convex. Neither")
    print("       algorithm is better in general -- that is the answer to give")


def linkage_on_iris():
    """unit-5.md 5.7: Ward agrees best with the truth, single scores best."""
    _, data = iris_frame()
    Z = StandardScaler().fit_transform(data.data)
    y = data.target

    rows = []
    for link in ("ward", "complete", "average", "single"):
        labels = AgglomerativeClustering(3, linkage=link).fit_predict(Z)
        rows.append((link, adjusted_rand_score(y, labels),
                     silhouette_score(Z, labels)))

    by_link = {name: (ari, sil) for name, ari, sil in rows}
    assert round(by_link["ward"][0], 4) == 0.6153
    assert round(by_link["single"][1], 4) == 0.5046
    assert by_link["ward"][0] == max(a for _, a, _ in rows), "Ward: best ARI"
    assert by_link["single"][1] == max(s for _, _, s in rows), \
        "single: best silhouette"
    assert by_link["ward"][1] < by_link["single"][1]

    print(f"    {'linkage':10} {'ARI vs species':>15} {'silhouette':>12}")
    for link, ari, sil in rows:
        print(f"    {link:10} {ari:15.4f} {sil:12.4f}")
    print("       WARD has the best agreement with the truth and the WORST")
    print("       silhouette; single linkage is the reverse. The same lesson as")
    print("       experiment 10: an internal metric rewards geometric tidiness,")
    print("       and tidiness is not correctness. Ward is the sensible default")


def main():
    print("Experiment 12 -- DBSCAN and density-based clustering")
    # Step 1: Cluster the crescents with DBSCAN and K-Means
    dbscan_beats_kmeans_on_non_convex_clusters()
    # Step 2: Try every k
    no_k_can_save_kmeans_here()
    # Step 3: Label core, border and noise points
    core_border_and_noise()
    # Step 4: Choose eps from the k-distance plot
    choosing_eps_with_a_k_distance_plot()
    # Step 5: Cluster blobs of different density
    print("  the weakness:")
    dbscan_struggles_with_varying_density()
    # Step 6: Compare hierarchical linkage
    print("  hierarchical linkage, for comparison:")
    linkage_on_iris()


if __name__ == "__main__":
    main()

5. Execution and Results

OUTPUT

Experiment 12 -- DBSCAN and density-based clustering
  two interleaved crescents, 300 points:
    K-Means (k=2)  ARI 0.2475
    DBSCAN         ARI 1.0000   2 clusters, 0 noise points
       DBSCAN recovers them PERFECTLY; K-Means is barely better
       than random
  the best ARI K-Means achieves over k = 2..10: 0.2938
       K-Means assigns each point to the NEAREST CENTROID, so its
       boundaries are straight perpendicular bisectors. A crescent
       cannot be carved out that way at ANY k. This is structural,
       not a tuning failure -- which is why the right answer is a
       different algorithm and not a better k
  eps=0.25: core 300, border 0, noise 0   (total 300)
  eps=0.10: noise rises to 26 points, 15 clusters
       a CORE point has >= min_samples neighbours within eps; a
       BORDER point is within eps of a core but is not core itself;
       everything else is NOISE and belongs to no cluster. K-Means
       has no such category -- it forces every point into a cluster
  k-distance plot with k = min_samples = 5:
    median 0.0842, 95th percentile 0.1421, max 0.2052
    eps = 0.1421 gives 2 clusters, 1 noise
       sort every point's distance to its k-th neighbour and take
       the ELBOW. Below it points are inside a dense region; above
       it they are not. minPts >= p+1, often 2p
  the weakness:
    a dense blob (sd 0.20, n=200) and a sparse one (sd 1.60, n=60),
    centres only 3 units apart:
       eps  clusters   noise      ARI  outcome
      0.30         1      60   1.0000  sparse blob DISCARDED as noise
      0.80         3      26   0.9307  sparse blob fragmented
      1.50         1       1   0.0178  the two MERGED
      2.50         1       0   0.0000  the two MERGED
    K-Means (k=2) on the same data: ARI 0.8335
       NO SINGLE eps recovers both: too small and the sparse cluster
       becomes 60 noise points, too large and the two merge. That is
       DBSCAN's real weakness, and OPTICS is the fix.
       Note the ARI of 1.0000 at eps=0.30 -- that is a METRIC
       ARTIFACT: all 60 sparse points got the single label -1, which
       ARI scores as a consistent group. A cluster of noise is not a
       cluster, and this is why you look at the counts as well.
       K-Means wins here because both blobs ARE convex. Neither
       algorithm is better in general -- that is the answer to give
  hierarchical linkage, for comparison:
    linkage     ARI vs species   silhouette
    ward                0.6153       0.4467
    complete            0.5726       0.4496
    average             0.5621       0.4803
    single              0.5584       0.5046
       WARD has the best agreement with the truth and the WORST
       silhouette; single linkage is the reverse. The same lesson as
       experiment 10: an internal metric rewards geometric tidiness,
       and tidiness is not correctness. Ward is the sensible default

DBSCAN'S OWN WEAKNESS, DEMONSTRATED HONESTLY

A dense blob and a sparse one, centres 3 units apart:

eps clusters noise ARI outcome
0.30 1 60 1.0000 sparse blob discarded as noise
0.80 3 26 0.9307 sparse blob fragmented
1.50 1 1 0.0178 the two merged
2.50 1 0 0.0000 the two merged

No single eps recovers both, and K-Means scores 0.8335 here because both blobs are convex. Neither algorithm is better in general.

WHY IT MATTERS

Note the ARI of 1.0000 at eps = 0.30 — a metric artefact. All 60 sparse points got the single label −1, which ARI scores as a consistent group. A cluster of noise is not a cluster, which is why you read the counts too.

RESULT

DBSCAN recovers the crescents exactly (ARI 1.0000) where no k lets K-Means pass 0.2938; no single eps recovers a dense and a sparse blob together.


Lab examination

An hour, a dataset, one experiment number, then a viva.

What costs marks:

What earns them:

Each program, on its own page

The same experiments, one page each, so a program can be reached by what it does rather than by its number.

RUNS

Import and export data with pandas

RUNS

Data pre-processing techniques in Python

RUNS

Dimensionality reduction with PCA in Python

RUNS

Data visualization techniques in Python

RUNS

Maximum likelihood estimation in Python

RUNS

Simple and multiple linear regression in Python

RUNS

Logistic regression in Python

RUNS

Decision tree classification in Python

RUNS

Naive Bayes classification in Python

RUNS

K-Means clustering in Python

RUNS

K-Nearest Neighbour classification in Python