Skip to the content
On this page
  1. WEKA in five minutes
  2. Experiment 1 — Load datasets and explore ARFF/CSV
  3. Experiment 2 — Data cleaning and missing values
  4. Experiment 3 — Normalization and discretization
  5. Experiment 4 — Attribute selection and PCA
  6. Experiment 5 — Summarize and visualize
  7. Experiment 6 — Association rules with Apriori
  8. Experiment 7 — Multilevel association rules
  9. Experiment 8 — K-Means clustering
  10. Experiment 9 — Hierarchical clustering and dendrograms
  11. Experiment 10 — EM clustering
  12. Experiment 11 — Decision tree with J48
  13. Experiment 12 — Naïve Bayes, compared with the tree
  14. Experiment 13 — Rule-based classification
  15. Experiment 14 — Compare classifiers: confusion matrix, accuracy, ROC
  16. Experiment 15 — Text preprocessing, TF-IDF and K-Means
  17. Lab examination
  18. Each program, on its own page

15 experiments, each set out as 1. Question, 2. Aim, 3. Steps, 4. Programme, 5. Execution and Results. Recommended datasets: weather.arff, iris.arff, supermarket.arff, vote.arff, contact-lenses.arff, or custom CSV.

Code lives in labs/course-8-datamining/.

NOTE

Both halves run. The syllabus prescribes WEKA, and WEKA 3.8.7 runs here, with the datasets that come with it. Each experiment has:

Until October 2026 WEKA could not be installed where these labs are checked, and the click-paths were marked NOT EXECUTED. WEKA's own site still cannot be reached; Maven Central serves its jar, and the Waikato repository its datasets, and tools/data-science/setup_weka.sh fetches both and checks each file's SHA-256. Experiment 7 has no WEKA half: WEKA has no multilevel association miner, as it says.

pip install -r tools/data-science/requirements.txt
tools/data-science/setup_weka.sh                  # WEKA and its datasets, in /tmp/weka
python3 tools/data-science/run_data_labs.py       # the Python halves, asserted
bash data-science/labs/course-8-datamining/11_decision_tree_weka.sh   # one WEKA half

WEKA reports how long each model took to build; those lines differ on every run, and capture_lab_outputs.py --check sets them aside.

WEKA in five minutes

DEFINITION

The Explorer is the interface you will be examined on.

Tab Purpose
Preprocess Load data, apply filters, view attribute statistics
Classify Build and evaluate classifiers
Cluster Build and evaluate clusterers
Associate Association rule mining
Select attributes Feature selection
Visualize Scatter-plot matrix

Filters are the heart of the Preprocess tab, and they divide two ways:

weka.filters
├── supervised          ← uses the class attribute
│   ├── attribute/      (Discretize, AttributeSelection)
│   └── instance/       (Resample, SMOTE)
└── unsupervised        ← ignores the class
    ├── attribute/      (Normalize, Standardize, Discretize,
    │                    ReplaceMissingValues, Remove, PrincipalComponents,
    │                    NumericToNominal, StringToWordVector)
    └── instance/       (RemoveWithValues, Randomize)

Choosing supervised versus unsupervised Discretize is itself an exam question: the supervised version uses the class label to place cut points where they best separate classes (Fayyad–Irani MDL), and generally produces better bins for a subsequent classifier.

Every Explorer choice is a WEKA class, and the command line names it: the Classify tab's trees/J48 is weka.classifiers.trees.J48, the Preprocess filter unsupervised/attribute/Normalize is weka.filters.unsupervised.attribute.Normalize. That is how the _weka.sh scripts repeat a click-path.

The ARFF format

@relation weather

@attribute outlook     {sunny, overcast, rainy}
@attribute temperature numeric
@attribute humidity    numeric
@attribute windy       {TRUE, FALSE}
@attribute play        {yes, no}

@data
sunny,85,85,FALSE,no
sunny,80,90,TRUE,no
overcast,83,86,FALSE,yes
?,70,96,FALSE,yes          % '?' is a missing value
Part Meaning
@relation Dataset name
@attribute name {a,b} Nominal — the brace list is the domain
@attribute name numeric Numeric
@attribute name string Free text
@attribute name date Date, with an optional format
@data Rows follow, comma-separated
? Missing value
% Comment

The last attribute is the class by default. Sparse ARFF, using {index value, index value}, stores only non-zero entries — which is what supermarket.arff uses.


Experiment 1 — Load datasets and explore ARFF/CSV

1. Question

Load datasets in ARFF and CSV formats, and explore their attributes.

2. Aim

Load data into WEKA, read its attribute summary, and convert between ARFF and CSV.

3. Steps

In WEKA, from the command line, 01_load_explore_weka.sh:

  1. Load the data, and read the Preprocess panel's summary (Open file...).
  2. Load the numeric version, whose temperature and humidity are numbers.
  3. Save it as CSV, and load the CSV back (Save..., then Open file... as CSV).
  4. Turn a numeric attribute into a nominal one (filter NumericToNominal).

In Python, 01_load_explore.py:

  1. Parse the ARFF file, and check its instances and attributes.
  2. Describe the data, as WEKA's Preprocess panel does.
  3. Write it as CSV and read it back.
  4. Turn a numeric-looking code into a category.

IN THE EXPLORER

  1. Explorer → Preprocess → Open file → data/weather.nominal.arff
  2. Read off: Instances 14, Attributes 5.
  3. Click each attribute. The right pane shows, for a nominal attribute, the label counts; for a numeric one, minimum, maximum, mean and standard deviation.

  4. Edit… opens the data as a table.

  5. To load CSV: Open file → change Files of Type to CSV data files.
  6. Save as… with an .arff extension converts it.

What to state in the viva. WEKA infers types from a CSV — a column of digits becomes numeric, anything else nominal. If a numeric-looking column is really a category (a pin code, a class ID), you must convert it with NumericToNominal or every algorithm will treat it as a magnitude. That conversion step is the most commonly forgotten part of this experiment.

4. Programme

In WEKA, from the command line, 01_load_explore_weka.sh:

#!/usr/bin/env bash
# Experiment 1 in WEKA 3.8.7, from the command line: load and explore ARFF and CSV.
# Each command runs the WEKA class that the Explorer runs for the same choice, and prints
# what the Explorer shows in its output pane. The click-path is on the lab page.
# Needs tools/data-science/setup_weka.sh; run as  bash 01_load_explore_weka.sh
set -euo pipefail
source "$(dirname "$0")/weka.sh"

# Step 1: Load the data, and read the Preprocess panel's summary (Open file...)
weka weka.core.Instances $DATA/weather.nominal.arff

# Step 2: Load the numeric version, whose temperature and humidity are numbers
weka weka.core.Instances $DATA/weather.numeric.arff

# Step 3: Save it as CSV, and load the CSV back (Save..., then Open file... as CSV)
weka weka.core.converters.CSVSaver -i $DATA/weather.numeric.arff -o weather.csv
cat weather.csv
weka weka.core.Instances weather.csv

# Step 4: Turn a numeric attribute into a nominal one (filter NumericToNominal)
weka weka.filters.unsupervised.attribute.NumericToNominal -R 2 -i weather.csv -o weather-nominal-temp.arff
weka weka.core.Instances weather-nominal-temp.arff

In Python, 01_load_explore.py:

"""Experiment 1 — Load datasets and explore ARFF/CSV formats.

WEKA equivalent: Preprocess tab -> Open file (see lab.md).
This prints the same summary WEKA's attribute panel shows, so you can compare.
"""
import io
import pandas as pd
from weather import weather_frame, ARFF


def parse_arff(text):
    """A minimal ARFF reader -- enough to show the format's structure.

    Real work uses scipy.io.arff or liac-arff; this exists so the format is
    legible rather than magic.
    """
    attributes, rows, in_data = [], [], False
    for line in text.splitlines():
        line = line.strip()
        if not line or line.startswith("%"):
            continue
        low = line.lower()
        if low.startswith("@attribute"):
            parts = line.split(None, 2)
            name, spec = parts[1], parts[2].strip()
            if spec.startswith("{"):
                kind, domain = "nominal", [v.strip() for v in spec.strip("{}").split(",")]
            else:
                kind, domain = spec.lower(), None
            attributes.append((name, kind, domain))
        elif low.startswith("@data"):
            in_data = True
        elif in_data:
            rows.append([None if v.strip() == "?" else v.strip()
                         for v in line.split(",")])
    return attributes, pd.DataFrame(rows, columns=[a[0] for a in attributes])


def describe(df, name):
    """What WEKA's Preprocess panel reports."""
    print(f"  {name}: {len(df)} instances, {len(df.columns)} attributes")
    for col in df.columns:
        missing = int(df[col].isna().sum())
        if pd.api.types.is_numeric_dtype(df[col]):
            print(f"    {col:14s} numeric  min={df[col].min():g} max={df[col].max():g} "
                  f"mean={df[col].mean():.4f} sd={df[col].std():.4f} missing={missing}")
        else:
            counts = df[col].value_counts().to_dict()
            print(f"    {col:14s} nominal  {counts} missing={missing}")


def main():
    print("Experiment 1 -- Load and explore")

    # Step 1: Parse the ARFF file, and check its instances and attributes
    attributes, df = parse_arff(ARFF)
    assert len(df) == 14, "weather.nominal has 14 instances"
    assert len(attributes) == 5, "and 5 attributes"
    assert all(a[1] == "nominal" for a in attributes), "all five are nominal"
    assert attributes[-1][0] == "play", "the LAST attribute is the class by default"
    # Step 2: Describe the data, as WEKA's Preprocess panel does
    describe(df, "weather.nominal.arff")

    # Round-trip through CSV, which is how WEKA imports non-ARFF data.
    # Step 3: Write it as CSV and read it back
    csv = df.to_csv(index=False)
    back = pd.read_csv(io.StringIO(csv))
    assert back.equals(df), "CSV round-trip must preserve the data"

    # The trap from lab.md: a numeric-looking CATEGORY.
    # Step 4: Turn a numeric-looking code into a category
    df2 = weather_frame()
    df2["ClassID"] = [101, 102, 103] * 4 + [104, 105]
    assert pd.api.types.is_integer_dtype(df2.ClassID), \
        "read as numeric -- every algorithm would treat it as a MAGNITUDE"
    df2["ClassID"] = df2.ClassID.astype("category")
    assert isinstance(df2.ClassID.dtype, pd.CategoricalDtype), \
        "NumericToNominal is the WEKA filter that fixes this"
    print("  numeric-looking category converted (WEKA: NumericToNominal)")
    print("  format checks passed")


if __name__ == "__main__":
    main()

5. Execution and Results

In WEKA, from the command line, 01_load_explore_weka.sh:

OUTPUT


$ java weka.core.Instances data/weather.nominal.arff
Relation Name:  weather.symbolic
Num Instances:  14
Num Attributes: 5

     Name                      Type  Nom  Int Real     Missing      Unique  Dist
1 outlook                    Nom 100%   0%   0%     0 /  0%     0 /  0%     3
2 temperature                Nom 100%   0%   0%     0 /  0%     0 /  0%     3
3 humidity                   Nom 100%   0%   0%     0 /  0%     0 /  0%     2
4 windy                      Nom 100%   0%   0%     0 /  0%     0 /  0%     2
5 play                       Nom 100%   0%   0%     0 /  0%     0 /  0%     2


$ java weka.core.Instances data/weather.numeric.arff
Relation Name:  weather
Num Instances:  14
Num Attributes: 5

     Name                      Type  Nom  Int Real     Missing      Unique  Dist
1 outlook                    Nom 100%   0%   0%     0 /  0%     0 /  0%     3
2 temperature                Num   0% 100%   0%     0 /  0%    10 / 71%    12
3 humidity                   Num   0% 100%   0%     0 /  0%     7 / 50%    10
4 windy                      Nom 100%   0%   0%     0 /  0%     0 /  0%     2
5 play                       Nom 100%   0%   0%     0 /  0%     0 /  0%     2


$ java weka.core.converters.CSVSaver -i data/weather.numeric.arff -o weather.csv
outlook,temperature,humidity,windy,play
sunny,85,85,FALSE,no
sunny,80,90,TRUE,no
overcast,83,86,FALSE,yes
rainy,70,96,FALSE,yes
rainy,68,80,FALSE,yes
rainy,65,70,TRUE,no
overcast,64,65,TRUE,yes
sunny,72,95,FALSE,no
sunny,69,70,FALSE,yes
rainy,75,80,FALSE,yes
sunny,75,70,TRUE,yes
overcast,72,90,TRUE,yes
overcast,81,75,FALSE,yes
rainy,71,91,TRUE,no

$ java weka.core.Instances weather.csv
Relation Name:  weather
Num Instances:  14
Num Attributes: 5

     Name                      Type  Nom  Int Real     Missing      Unique  Dist
1 outlook                    Nom 100%   0%   0%     0 /  0%     0 /  0%     3
2 temperature                Num   0% 100%   0%     0 /  0%    10 / 71%    12
3 humidity                   Num   0% 100%   0%     0 /  0%     7 / 50%    10
4 windy                      Nom 100%   0%   0%     0 /  0%     0 /  0%     2
5 play                       Nom 100%   0%   0%     0 /  0%     0 /  0%     2


$ java weka.filters.unsupervised.attribute.NumericToNominal -R 2 -i weather.csv -o weather-nominal-temp.arff

$ java weka.core.Instances weather-nominal-temp.arff
Relation Name:  weather-weka.filters.unsupervised.attribute.NumericToNominal-R2
Num Instances:  14
Num Attributes: 5

     Name                      Type  Nom  Int Real     Missing      Unique  Dist
1 outlook                    Nom 100%   0%   0%     0 /  0%     0 /  0%     3
2 temperature                Nom 100%   0%   0%     0 /  0%    10 / 71%    12
3 humidity                   Num   0% 100%   0%     0 /  0%     7 / 50%    10
4 windy                      Nom 100%   0%   0%     0 /  0%     0 /  0%     2
5 play                       Nom 100%   0%   0%     0 /  0%     0 /  0%     2

In Python, 01_load_explore.py:

OUTPUT

Experiment 1 -- Load and explore
  weather.nominal.arff: 14 instances, 5 attributes
    outlook        nominal  {'Sunny': 5, 'Rain': 5, 'Overcast': 4} missing=0
    temperature    nominal  {'Mild': 6, 'Hot': 4, 'Cool': 4} missing=0
    humidity       nominal  {'High': 7, 'Normal': 7} missing=0
    windy          nominal  {'Weak': 8, 'Strong': 6} missing=0
    play           nominal  {'Yes': 9, 'No': 5} missing=0
  numeric-looking category converted (WEKA: NumericToNominal)
  format checks passed

WEKA's summary is the Preprocess panel as a table: Nom, Int and Real are the shares of each attribute's values that are nominal, whole numbers and decimals. Loaded back from CSV, temperature is numeric (Int 100%, 12 distinct values); after NumericToNominal it is nominal, with one label per value. The Python half parses the same ARFF and checks the round trip.

RESULT

weather.nominal has 14 instances and 5 nominal attributes, the class last. CSV keeps the data but not its types, and NumericToNominal turns a number into a category.

Experiment 2 — Data cleaning and missing values

1. Question

Clean a dataset that has missing values.

2. Aim

Find and replace missing values in WEKA, and see what mean imputation does to the data.

3. Steps

In WEKA, from the command line, 02_missing_values_weka.sh:

  1. Load labor.arff, and count its missing values (the Missing column).
  2. Replace them, by mean and mode (filter ReplaceMissingValues).
  3. Count them again.

In Python, 02_missing_values.py:

  1. Impute the worked example's missing ages.
  2. See mean imputation shrink the variance.
  3. Compare the imputation strategies.
  4. Flag the missing values before imputing.
  5. Impute after splitting, not before.

IN THE EXPLORER

  1. Load a dataset with missing values (labor.arff, or weather with ? inserted).

  2. Preprocess → Choose → filters/unsupervised/attribute/ReplaceMissingValues → Apply.

  3. Read the attribute panel: the Missing count falls to 0.

WEKA's ReplaceMissingValues uses the mean for numeric attributes and the mode for nominal ones. To drop rows instead, use filters/unsupervised/instance/RemoveWithValues with matchMissingValues = True.

4. Programme

In WEKA, from the command line, 02_missing_values_weka.sh:

#!/usr/bin/env bash
# Experiment 2 in WEKA 3.8.7, from the command line: replace missing values.
# Each command runs the WEKA class that the Explorer runs for the same choice, and prints
# what the Explorer shows in its output pane. The click-path is on the lab page.
# Needs tools/data-science/setup_weka.sh; run as  bash 02_missing_values_weka.sh
set -euo pipefail
source "$(dirname "$0")/weka.sh"

# Step 1: Load labor.arff, and count its missing values (the Missing column)
weka weka.core.Instances $DATA/labor.arff

# Step 2: Replace them, by mean and mode (filter ReplaceMissingValues)
weka weka.filters.unsupervised.attribute.ReplaceMissingValues -i $DATA/labor.arff -o labor-filled.arff

# Step 3: Count them again
weka weka.core.Instances labor-filled.arff

In Python, 02_missing_values.py:

"""Experiment 2 — Data cleaning and missing values.

WEKA equivalent: filters/unsupervised/attribute/ReplaceMissingValues.

Demonstrates NUMERICALLY the variance shrinkage warned about in Unit 2 section
2.5 -- the reason mean imputation is more dangerous than it looks.
"""
import numpy as np
import pandas as pd
from sklearn.impute import SimpleImputer, KNNImputer


def unit2_worked_example():
    """Section 2.5: ages [25, 30, NULL, 35, 40, NULL, 28]."""
    ages = pd.Series([25, 30, np.nan, 35, 40, np.nan, 28])
    known = ages.dropna()

    assert known.sum() == 158 and len(known) == 5
    assert round(known.mean(), 4) == 31.6
    assert known.median() == 30

    # One outlier moves the mean far more than the median.
    with_outlier = pd.concat([known, pd.Series([200])])
    assert round(with_outlier.mean(), 1) == 59.7
    assert with_outlier.median() == 32.5

    print(f"  2.5: mean {known.mean():.1f}, median {known.median():.0f}; "
          f"add one 200 -> mean {with_outlier.mean():.1f}, "
          f"median {with_outlier.median():.1f}")


def variance_shrinkage():
    """Mean imputation keeps the mean and DESTROYS the spread."""
    rng = np.random.default_rng(42)
    full = rng.normal(50, 10, 1000)

    holed = full.copy()
    holed[rng.choice(1000, 300, replace=False)] = np.nan   # 30% missing

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

    assert abs(np.nanmean(holed) - imputed.mean()) < 1e-9, "the MEAN is preserved"
    assert imputed.std() < np.nanstd(holed), "but the STANDARD DEVIATION shrinks"

    shrink = 1 - imputed.std() / np.nanstd(holed)
    # Filling 30% of the values with a constant removes their contribution to
    # the spread, so sd falls by roughly 1 - sqrt(0.7) = 16.3%.
    assert 0.10 < shrink < 0.25, shrink

    print(f"  variance shrinkage: sd {np.nanstd(holed):.4f} -> {imputed.std():.4f} "
          f"({shrink:.1%} lost) while the mean is unchanged")
    print(f"       every correlation weakens and every test becomes overconfident")


def imputation_strategies():
    """The methods from Unit 2's table, on data with a deliberate outlier."""
    df = pd.DataFrame({
        "age":    [25, 30, np.nan, 35, 40, np.nan, 28, 200],
        "income": [30000, 45000, 52000, np.nan, 61000, 38000, np.nan, 90000],
        "city":   ["VJA", "GNT", None, "VJA", "VJA", "GNT", None, "HYD"],
    })

    numeric = ["age", "income"]
    mean_i = pd.DataFrame(SimpleImputer(strategy="mean").fit_transform(df[numeric]),
                          columns=numeric)
    med_i = pd.DataFrame(SimpleImputer(strategy="median").fit_transform(df[numeric]),
                         columns=numeric)
    knn_i = pd.DataFrame(KNNImputer(n_neighbors=2).fit_transform(df[numeric]),
                         columns=numeric)
    mode_city = SimpleImputer(strategy="most_frequent").fit_transform(df[["city"]])

    # The 200 drags the mean-imputed value well above the median-imputed one.
    assert mean_i.age[2] > med_i.age[2], "the outlier inflates the mean"
    assert mode_city[2][0] == "VJA", "mode imputation picks the commonest city"
    assert not mean_i.isna().any().any() and not knn_i.isna().any().any()

    print(f"  imputed age: mean {mean_i.age[2]:.2f}, median {med_i.age[2]:.2f}, "
          f"kNN {knn_i.age[2]:.2f}")
    print(f"       the median is unmoved by the 200 -- prefer it on real data")


def missingness_is_information():
    """Section 2.5: add an indicator BEFORE imputing."""
    df = pd.DataFrame({"income": [30000, np.nan, 52000, np.nan, 61000]})
    df["income_missing"] = df.income.isna().astype(int)
    assert df.income_missing.tolist() == [0, 1, 0, 1, 0]
    print("  added an income_missing indicator -- a tree often finds it predictive")


def impute_after_splitting():
    """Fitting the imputer on ALL data leaks test information into training."""
    rng = np.random.default_rng(0)
    train = np.array([10.0, 12.0, np.nan, 11.0])
    test = np.array([1000.0, np.nan])

    correct = SimpleImputer(strategy="mean").fit(train.reshape(-1, 1))
    leaky = SimpleImputer(strategy="mean").fit(
        np.concatenate([train, test]).reshape(-1, 1))

    assert round(float(correct.statistics_[0]), 4) == 11.0
    assert float(leaky.statistics_[0]) > 200, "the test set's 1000 has leaked in"

    print(f"  leakage: fit on train only -> {correct.statistics_[0]:.2f}; "
          f"fit on everything -> {leaky.statistics_[0]:.2f}")


def main():
    print("Experiment 2 -- Missing values")
    # Step 1: Impute the worked example's missing ages
    unit2_worked_example()
    # Step 2: See mean imputation shrink the variance
    variance_shrinkage()
    # Step 3: Compare the imputation strategies
    imputation_strategies()
    # Step 4: Flag the missing values before imputing
    missingness_is_information()
    # Step 5: Impute after splitting, not before
    impute_after_splitting()
    print("  all Unit 2 missing-data claims verified")


if __name__ == "__main__":
    main()

5. Execution and Results

In WEKA, from the command line, 02_missing_values_weka.sh:

OUTPUT


$ java weka.core.Instances data/labor.arff
Relation Name:  labor-neg-data
Num Instances:  57
Num Attributes: 17

     Name                      Type  Nom  Int Real     Missing      Unique  Dist
 1 duration                   Num   0%  98%   0%     1 /  2%     0 /  0%     3
 2 wage-increase-first-year   Num   0%  49%  49%     1 /  2%     7 / 12%    17
 3 wage-increase-second-year  Num   0%  47%  33%    11 / 19%     8 / 14%    15
 4 wage-increase-third-year   Num   0%  14%  12%    42 / 74%     6 / 11%     9
 5 cost-of-living-adjustment  Nom  65%   0%   0%    20 / 35%     0 /  0%     3
 6 working-hours              Num   0%  89%   0%     6 / 11%     3 /  5%     8
 7 pension                    Nom  47%   0%   0%    30 / 53%     0 /  0%     3
 8 standby-pay                Num   0%  16%   0%    48 / 84%     6 / 11%     7
 9 shift-differential         Num   0%  54%   0%    26 / 46%     5 /  9%    10
10 education-allowance        Nom  39%   0%   0%    35 / 61%     0 /  0%     2
11 statutory-holidays         Num   0%  93%   0%     4 /  7%     0 /  0%     6
12 vacation                   Nom  89%   0%   0%     6 / 11%     0 /  0%     3
13 longterm-disability-assis  Nom  49%   0%   0%    29 / 51%     0 /  0%     2
14 contribution-to-dental-pl  Nom  65%   0%   0%    20 / 35%     0 /  0%     3
15 bereavement-assistance     Nom  53%   0%   0%    27 / 47%     0 /  0%     2
16 contribution-to-health-pl  Nom  65%   0%   0%    20 / 35%     0 /  0%     3
17 class                      Nom 100%   0%   0%     0 /  0%     0 /  0%     2


$ java weka.filters.unsupervised.attribute.ReplaceMissingValues -i data/labor.arff -o labor-filled.arff

$ java weka.core.Instances labor-filled.arff
Relation Name:  labor-neg-data-weka.filters.unsupervised.attribute.ReplaceMissingValues
Num Instances:  57
Num Attributes: 17

     Name                      Type  Nom  Int Real     Missing      Unique  Dist
 1 duration                   Num   0%  98%   2%     0 /  0%     1 /  2%     4
 2 wage-increase-first-year   Num   0%  49%  51%     0 /  0%     8 / 14%    18
 3 wage-increase-second-year  Num   0%  47%  53%     0 /  0%     8 / 14%    16
 4 wage-increase-third-year   Num   0%  14%  86%     0 /  0%     6 / 11%    10
 5 cost-of-living-adjustment  Nom 100%   0%   0%     0 /  0%     0 /  0%     3
 6 working-hours              Num   0%  89%  11%     0 /  0%     3 /  5%     9
 7 pension                    Nom 100%   0%   0%     0 /  0%     0 /  0%     3
 8 standby-pay                Num   0%  16%  84%     0 /  0%     6 / 11%     8
 9 shift-differential         Num   0%  54%  46%     0 /  0%     5 /  9%    11
10 education-allowance        Nom 100%   0%   0%     0 /  0%     0 /  0%     2
11 statutory-holidays         Num   0%  93%   7%     0 /  0%     0 /  0%     7
12 vacation                   Nom 100%   0%   0%     0 /  0%     0 /  0%     3
13 longterm-disability-assis  Nom 100%   0%   0%     0 /  0%     0 /  0%     2
14 contribution-to-dental-pl  Nom 100%   0%   0%     0 /  0%     0 /  0%     3
15 bereavement-assistance     Nom 100%   0%   0%     0 /  0%     0 /  0%     2
16 contribution-to-health-pl  Nom 100%   0%   0%     0 /  0%     0 /  0%     3
17 class                      Nom 100%   0%   0%     0 /  0%     0 /  0%     2

In Python, 02_missing_values.py:

OUTPUT

Experiment 2 -- Missing values
  2.5: mean 31.6, median 30; add one 200 -> mean 59.7, median 32.5
  variance shrinkage: sd 9.7862 -> 8.1877 (16.3% lost) while the mean is unchanged
       every correlation weakens and every test becomes overconfident
  imputed age: mean 59.67, median 32.50, kNN 35.00
       the median is unmoved by the 200 -- prefer it on real data
  added an income_missing indicator -- a tree often finds it predictive
  leakage: fit on train only -> 11.00; fit on everything -> 258.25
  all Unit 2 missing-data claims verified

labor.arff has missing values in 16 of its 17 attributes — 42 of 57 in wage-increase-third-year, 48 in standby-pay. After the filter, every Missing count is 0.

The Python half implements mean, median, mode and k-NN imputation on the same idea and demonstrates the variance shrinkage from Unit 2 §2.5 numerically: imputing 30% of a column with its mean measurably lowers the standard deviation, and the script asserts it.

RESULT

ReplaceMissingValues fills every gap in labor.arff, by mean and mode; the Python half shows the cost, a smaller spread, and asserts it.

Experiment 3 — Normalization and discretization

1. Question

Normalise and discretise numeric attributes.

2. Aim

Scale attributes to a common range, and turn numbers into bins three ways.

3. Steps

In WEKA, from the command line, 03_normalize_discretize_weka.sh:

  1. Normalise every numeric attribute to [0, 1] (filter Normalize).
  2. Discretise into 3 equal-width bins (filter Discretize, bins = 3).
  3. And into 3 equal-frequency bins (useEqualFrequency = True).
  4. Discretise using the class, by Fayyad-Irani MDL (the supervised Discretize).

In Python, 03_normalize_discretize.py:

  1. Normalise by min-max and by z-score.
  2. See one outlier crush min-max scaling.
  3. Smooth by equal-frequency binning.
  4. Discretise the ages into three bins.
  5. Binarise a category, rather than numbering it.
  6. Find which algorithms need scaling.

IN THE EXPLORER

Normalize: filters/unsupervised/attribute/Normalize → scales every numeric attribute to [0, 1] (min–max). Standardize gives mean 0, variance 1 (z-score).

Discretize: filters/unsupervised/attribute/Discretize

Supervised discretization (filters/supervised/attribute/Discretize) uses the class to place the cuts and often produces fewer, better bins — sometimes one bin, meaning the attribute is useless.

4. Programme

In WEKA, from the command line, 03_normalize_discretize_weka.sh:

#!/usr/bin/env bash
# Experiment 3 in WEKA 3.8.7, from the command line: normalise and discretise.
# Each command runs the WEKA class that the Explorer runs for the same choice, and prints
# what the Explorer shows in its output pane. The click-path is on the lab page.
# Needs tools/data-science/setup_weka.sh; run as  bash 03_normalize_discretize_weka.sh
set -euo pipefail
source "$(dirname "$0")/weka.sh"

# Step 1: Normalise every numeric attribute to [0, 1] (filter Normalize)
weka weka.filters.unsupervised.attribute.Normalize -i $DATA/iris.arff -o iris-normalised.arff
weka weka.core.Instances iris-normalised.arff

# Step 2: Discretise into 3 equal-width bins (filter Discretize, bins = 3)
weka weka.filters.unsupervised.attribute.Discretize -B 3 -R first-last -i $DATA/iris.arff -o iris-width.arff
grep "^@attribute" iris-width.arff

# Step 3: And into 3 equal-frequency bins (useEqualFrequency = True)
weka weka.filters.unsupervised.attribute.Discretize -B 3 -F -R first-last -i $DATA/iris.arff -o iris-freq.arff
grep "^@attribute" iris-freq.arff

# Step 4: Discretise using the class, by Fayyad-Irani MDL (the supervised Discretize)
weka weka.filters.supervised.attribute.Discretize -R first-last -c last -i $DATA/iris.arff -o iris-supervised.arff
grep "^@attribute" iris-supervised.arff

In Python, 03_normalize_discretize.py:

"""Experiment 3 — Normalization and discretization.

WEKA equivalents: filters/unsupervised/attribute/{Normalize, Standardize,
Discretize} and filters/supervised/attribute/Discretize.

Reproduces Unit 2 sections 2.9 and 2.10 exactly.
"""
import numpy as np
import pandas as pd
from sklearn.preprocessing import (MinMaxScaler, StandardScaler, RobustScaler,
                                   KBinsDiscretizer)


def unit2_normalisation():
    """Section 2.10: income 73600, min 12000, max 98000, mu 54000, sd 16000."""
    x, lo, hi, mu, sd = 73600, 12000, 98000, 54000, 16000

    assert round((x - lo) / (hi - lo), 4) == 0.7163
    assert round((x - mu) / sd, 4) == 1.225
    j = len(str(hi))                       # smallest j with max|x'| < 1
    assert j == 5 and round(x / 10 ** j, 4) == 0.736

    print(f"  2.10: min-max {(x-lo)/(hi-lo):.4f}, z-score {(x-mu)/sd:.4f}, "
          f"decimal {x/10**j:.4f}")


def outlier_destroys_minmax():
    """Section 2.10: one outlier crushes every other value into a tiny range."""
    v = np.array([[10.0], [12.0], [11.0], [13.0], [1000.0]])
    scaled = MinMaxScaler().fit_transform(v).ravel()

    assert [round(s, 4) for s in scaled] == [0.0, 0.002, 0.001, 0.003, 1.0], scaled
    assert scaled[:4].max() < 0.01, "four sensible values inside 1% of the range"

    z = StandardScaler().fit_transform(v).ravel()
    r = RobustScaler().fit_transform(v).ravel()
    assert abs(z[:4]).max() < abs(z[4]), "z-score also flags the outlier, less brutally"
    assert abs(r[:4]).max() < 2, "robust scaling keeps the sensible values sensible"

    print(f"  outlier: min-max gives {[round(float(s), 4) for s in scaled]}")
    print(f"       robust  gives {[round(float(s), 2) for s in r]}")
    print(f"       min-max crushes the four real values into 0.3% of the range;")
    print(f"       robust keeps them spread and leaves the outlier VISIBLE as one")


def unit2_binning():
    """Section 2.6: prices 4,8,9,15,21,21,24,25,26,28,29,34 in equal-frequency
    bins of depth 4, smoothed three ways."""
    v = [4, 8, 9, 15, 21, 21, 24, 25, 26, 28, 29, 34]
    bins = [v[0:4], v[4:8], v[8:12]]

    means = [sum(b) / len(b) for b in bins]
    assert means == [9.0, 22.75, 29.25], means

    medians = [float(np.median(b)) for b in bins]
    assert medians[0] == 8.5

    def boundaries(b):
        lo, hi = min(b), max(b)
        return [lo if abs(x - lo) <= abs(x - hi) else hi for x in b]

    assert boundaries(bins[0]) == [4, 4, 4, 15], boundaries(bins[0])

    print(f"  2.6: bin means {means}; bin-1 boundaries {boundaries(bins[0])}")


def unit2_discretization():
    """Section 2.9: ages 8,15,22,25,31,38,44,51,67 into 3 bins."""
    ages = np.array([8, 15, 22, 25, 31, 38, 44, 51, 67], dtype=float)

    rng = ages.max() - ages.min()
    assert rng == 59
    width = rng / 3
    assert round(width, 2) == 19.67
    edges = [ages.min() + width, ages.min() + 2 * width]
    assert [round(e, 2) for e in edges] == [27.67, 47.33], edges

    ew = KBinsDiscretizer(n_bins=3, encode="ordinal", strategy="uniform",
                          quantile_method="averaged_inverted_cdf"
                          ).fit(ages.reshape(-1, 1))
    got = [round(float(e), 2) for e in ew.bin_edges_[0]]
    assert got == [8.0, 27.67, 47.33, 67.0], got
    counts = np.bincount(ew.transform(ages.reshape(-1, 1)).ravel().astype(int))
    assert counts.tolist() == [4, 3, 2], "equal WIDTH gives uneven counts"

    ef = KBinsDiscretizer(n_bins=3, encode="ordinal", strategy="quantile",
                          quantile_method="averaged_inverted_cdf"
                          ).fit(ages.reshape(-1, 1))
    fcounts = np.bincount(ef.transform(ages.reshape(-1, 1)).ravel().astype(int))
    assert fcounts.tolist() == [3, 3, 3], "equal FREQUENCY gives equal counts"

    print(f"  2.9: equal-width edges {got} -> counts {counts.tolist()}")
    print(f"       equal-frequency -> counts {fcounts.tolist()}")


def binarization_trap():
    """Section 2.9: integer-encoding an UNORDERED category is a bug."""
    colours = ["red", "green", "blue", "red"]

    integer = pd.Series(colours).map({"red": 1, "green": 2, "blue": 3})
    # Under this encoding green is exactly BETWEEN red and blue, which is false.
    assert integer[1] == (integer[0] + integer[2]) / 2, \
        "the arithmetic that integer encoding invents"

    onehot = pd.get_dummies(pd.Series(colours), prefix="is")
    assert list(onehot.columns) == ["is_blue", "is_green", "is_red"]
    assert onehot.sum(axis=1).tolist() == [1, 1, 1, 1], "exactly one 1 per row"

    # The dummy variable trap: k columns are perfectly collinear.
    dropped = pd.get_dummies(pd.Series(colours), drop_first=True)
    assert dropped.shape[1] == 2, "k-1 columns for linear models"

    # An ORDERED category is different -- integers preserve real information.
    ordinal = pd.Series(["low", "high", "medium"]).map({"low": 1, "medium": 2, "high": 3})
    assert ordinal.tolist() == [1, 3, 2]

    print("  one-hot for unordered, integers only where the order is real")
    print("       (k-1 columns for linear models -- the dummy variable trap)")


def which_algorithms_need_scaling():
    """Section 2.10: trees do NOT need scaling; distance methods do."""
    from sklearn.tree import DecisionTreeClassifier
    from sklearn.neighbors import KNeighborsClassifier
    from sklearn.datasets import load_iris
    from sklearn.model_selection import cross_val_score

    X, y = load_iris(return_X_y=True)
    X_skewed = X.copy()
    X_skewed[:, 0] *= 10000                 # blow up one feature's scale

    tree_raw = cross_val_score(DecisionTreeClassifier(random_state=0), X, y, cv=5).mean()
    tree_skew = cross_val_score(DecisionTreeClassifier(random_state=0), X_skewed, y, cv=5).mean()
    assert abs(tree_raw - tree_skew) < 1e-12, \
        "a tree splits on ORDER, which scaling does not change"

    knn_raw = cross_val_score(KNeighborsClassifier(), X, y, cv=5).mean()
    knn_skew = cross_val_score(KNeighborsClassifier(), X_skewed, y, cv=5).mean()
    assert knn_skew < knn_raw, "k-NN is broken by the rescaled feature"

    print(f"  scaling: tree {tree_raw:.4f} -> {tree_skew:.4f} (IDENTICAL); "
          f"k-NN {knn_raw:.4f} -> {knn_skew:.4f} (broken)")


def main():
    print("Experiment 3 -- Normalization and discretization")
    # Step 1: Normalise by min-max and by z-score
    unit2_normalisation()
    # Step 2: See one outlier crush min-max scaling
    outlier_destroys_minmax()
    # Step 3: Smooth by equal-frequency binning
    unit2_binning()
    # Step 4: Discretise the ages into three bins
    unit2_discretization()
    # Step 5: Binarise a category, rather than numbering it
    binarization_trap()
    # Step 6: Find which algorithms need scaling
    which_algorithms_need_scaling()
    print("  all Unit 2 transformation claims verified")


if __name__ == "__main__":
    main()

5. Execution and Results

In WEKA, from the command line, 03_normalize_discretize_weka.sh:

OUTPUT


$ java weka.filters.unsupervised.attribute.Normalize -i data/iris.arff -o iris-normalised.arff

$ java weka.core.Instances iris-normalised.arff
Relation Name:  iris-weka.filters.unsupervised.attribute.Normalize-S1.0-T0.0
Num Instances:  150
Num Attributes: 5

     Name                      Type  Nom  Int Real     Missing      Unique  Dist
1 sepallength                Num   0%   1%  99%     0 /  0%     9 /  6%    35
2 sepalwidth                 Num   0%   1%  99%     0 /  0%     5 /  3%    23
3 petallength                Num   0%   1%  99%     0 /  0%    10 /  7%    43
4 petalwidth                 Num   0%   6%  94%     0 /  0%     2 /  1%    22
5 class                      Nom 100%   0%   0%     0 /  0%     0 /  0%     3


$ java weka.filters.unsupervised.attribute.Discretize -B 3 -R first-last -i data/iris.arff -o iris-width.arff
@attribute sepallength {'\'(-inf-5.5]\'','\'(5.5-6.7]\'','\'(6.7-inf)\''}
@attribute sepalwidth {'\'(-inf-2.8]\'','\'(2.8-3.6]\'','\'(3.6-inf)\''}
@attribute petallength {'\'(-inf-2.966667]\'','\'(2.966667-4.933333]\'','\'(4.933333-inf)\''}
@attribute petalwidth {'\'(-inf-0.9]\'','\'(0.9-1.7]\'','\'(1.7-inf)\''}
@attribute class {Iris-setosa,Iris-versicolor,Iris-virginica}

$ java weka.filters.unsupervised.attribute.Discretize -B 3 -F -R first-last -i data/iris.arff -o iris-freq.arff
@attribute sepallength {'\'(-inf-5.45]\'','\'(5.45-6.25]\'','\'(6.25-inf)\''}
@attribute sepalwidth {'\'(-inf-2.85]\'','\'(2.85-3.15]\'','\'(3.15-inf)\''}
@attribute petallength {'\'(-inf-2.45]\'','\'(2.45-4.85]\'','\'(4.85-inf)\''}
@attribute petalwidth {'\'(-inf-0.8]\'','\'(0.8-1.65]\'','\'(1.65-inf)\''}
@attribute class {Iris-setosa,Iris-versicolor,Iris-virginica}

$ java weka.filters.supervised.attribute.Discretize -R first-last -c last -i data/iris.arff -o iris-supervised.arff
@attribute sepallength {'\'(-inf-5.55]\'','\'(5.55-6.15]\'','\'(6.15-inf)\''}
@attribute sepalwidth {'\'(-inf-2.95]\'','\'(2.95-3.35]\'','\'(3.35-inf)\''}
@attribute petallength {'\'(-inf-2.45]\'','\'(2.45-4.75]\'','\'(4.75-inf)\''}
@attribute petalwidth {'\'(-inf-0.8]\'','\'(0.8-1.75]\'','\'(1.75-inf)\''}
@attribute class {Iris-setosa,Iris-versicolor,Iris-virginica}

In Python, 03_normalize_discretize.py:

OUTPUT

Experiment 3 -- Normalization and discretization
  2.10: min-max 0.7163, z-score 1.2250, decimal 0.7360
  outlier: min-max gives [0.0, 0.002, 0.001, 0.003, 1.0]
       robust  gives [-1.0, 0.0, -0.5, 0.5, 494.0]
       min-max crushes the four real values into 0.3% of the range;
       robust keeps them spread and leaves the outlier VISIBLE as one
  2.6: bin means [9.0, 22.75, 29.25]; bin-1 boundaries [4, 4, 4, 15]
  2.9: equal-width edges [8.0, 27.67, 47.33, 67.0] -> counts [4, 3, 2]
       equal-frequency -> counts [3, 3, 3]
  one-hot for unordered, integers only where the order is real
       (k-1 columns for linear models -- the dummy variable trap)
  scaling: tree 0.9600 -> 0.9600 (IDENTICAL); k-NN 0.9733 -> 0.7400 (broken)
  all Unit 2 transformation claims verified

The three discretisations of petallength show the difference. Equal width cuts at 2.97 and 4.93, a third of the range each; equal frequency at 2.45 and 4.85, so each bin holds about 50 flowers; the supervised version cuts at 2.45 and 4.75, where the species change.

The Python half reproduces Unit 2's worked examples exactly: min–max of 25 in that twelve-value set is 0.1477; equal-width bins of the ages have edges 27.67 and 47.33; and equal-frequency bins hold three values each. All asserted.

RESULT

Normalize maps every measurement to [0, 1]; Discretize makes three bins by width or by frequency, and the supervised version places its cuts by the class.

Experiment 4 — Attribute selection and PCA

1. Question

Select the most useful attributes, by a filter and by a wrapper, and reduce the data with PCA.

2. Aim

Rank and select attributes in WEKA, and transform them into principal components.

3. Steps

In WEKA, from the command line, 04_feature_selection_weka.sh:

  1. Rank the attributes by information gain (InfoGainAttributeEval, Ranker).
  2. Choose a subset with a wrapper round J48 (WrapperSubsetEval, BestFirst).
  3. Principal components of the four measurements, with their eigenvalues.
  4. Replace the attributes by the components, as the Preprocess filter does.

In Python, 04_feature_selection.py:

  1. Rank the attributes by information gain.
  2. Select a subset with a wrapper.
  3. Read the worked example's eigenvalues.
  4. Count the components for 90% of the variance.
  5. Standardise before PCA.
  6. See what PCA costs in interpretability.

IN THE EXPLORER

Filter approach: Select attributes tab

Wrapper approach:

PCA: Preprocess → filters/unsupervised/attribute/PrincipalComponents

Read that attribute name carefully in the viva — it is the eigenvector, and it is exactly why PCA costs you interpretability.

4. Programme

In WEKA, from the command line, 04_feature_selection_weka.sh:

#!/usr/bin/env bash
# Experiment 4 in WEKA 3.8.7, from the command line: attribute selection and PCA.
# Each command runs the WEKA class that the Explorer runs for the same choice, and prints
# what the Explorer shows in its output pane. The click-path is on the lab page.
# Needs tools/data-science/setup_weka.sh; run as  bash 04_feature_selection_weka.sh
set -euo pipefail
source "$(dirname "$0")/weka.sh"

# Step 1: Rank the attributes by information gain (InfoGainAttributeEval, Ranker)
weka weka.attributeSelection.InfoGainAttributeEval -s "weka.attributeSelection.Ranker" -i $DATA/iris.arff

# Step 2: Choose a subset with a wrapper round J48 (WrapperSubsetEval, BestFirst)
weka weka.attributeSelection.WrapperSubsetEval -B weka.classifiers.trees.J48 -F 5 -R 1 -s "weka.attributeSelection.BestFirst" -i $DATA/iris.arff

# Step 3: Principal components of the four measurements, with their eigenvalues
# (PrincipalComponents, Ranker). The class is removed first: given it, this
# evaluator mixes the species into the components as three more numbers.
weka weka.filters.unsupervised.attribute.Remove -R last -i $DATA/iris.arff -o iris-measurements.arff
weka weka.attributeSelection.PrincipalComponents -R 0.95 -s "weka.attributeSelection.Ranker" -i iris-measurements.arff

# Step 4: Replace the attributes by the components, as the Preprocess filter does
# (filters/unsupervised/attribute/PrincipalComponents, varianceCovered = 0.95)
weka weka.filters.unsupervised.attribute.PrincipalComponents -R 0.95 -c last -i $DATA/iris.arff -o iris-pca.arff
grep "^@attribute" iris-pca.arff

In Python, 04_feature_selection.py:

"""Experiment 4 — Attribute selection and PCA.

WEKA: Select attributes tab (InfoGainAttributeEval + Ranker, WrapperSubsetEval
+ BestFirst) and filters/unsupervised/attribute/PrincipalComponents.
"""
import numpy as np
from sklearn.datasets import load_iris
from sklearn.decomposition import PCA
from sklearn.feature_selection import mutual_info_classif, RFE
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import cross_val_score
from sklearn.tree import DecisionTreeClassifier


def filter_ranking():
    """WEKA's InfoGainAttributeEval + Ranker."""
    X, y = load_iris(return_X_y=True)
    names = load_iris().feature_names

    mi = mutual_info_classif(X, y, random_state=0)
    ranked = sorted(zip(names, mi), key=lambda p: -p[1])

    # Petal measurements dominate on iris -- the visual separation of Exp 5.
    top_two = {n for n, _ in ranked[:2]}
    assert top_two == {"petal length (cm)", "petal width (cm)"}, top_two

    print("  info gain ranking (WEKA: InfoGainAttributeEval + Ranker)")
    for n, v in ranked:
        print(f"    {v:.4f}  {n}")


def wrapper_selection():
    """WEKA's WrapperSubsetEval: trains the model on each candidate subset."""
    X, y = load_iris(return_X_y=True)
    rfe = RFE(LogisticRegression(max_iter=1000), n_features_to_select=2).fit(X, y)
    kept = [n for n, k in zip(load_iris().feature_names, rfe.support_) if k]
    assert len(kept) == 2

    full = cross_val_score(DecisionTreeClassifier(random_state=0), X, y, cv=5).mean()
    subset = cross_val_score(DecisionTreeClassifier(random_state=0),
                             X[:, rfe.support_], y, cv=5).mean()

    print(f"  wrapper (RFE) kept: {kept}")
    print(f"       4 attributes {full:.4f} vs 2 attributes {subset:.4f}")
    print(f"       half the features, essentially the same accuracy")


def unit2_eigenvalue_table():
    """Section 2.7's worked example: eigenvalues 4.2, 2.1, 0.9, 0.5, 0.3."""
    lam = np.array([4.2, 2.1, 0.9, 0.5, 0.3])
    total = lam.sum()
    assert round(float(total), 4) == 8.0

    prop = lam / total
    cum = np.cumsum(prop)
    assert [round(float(p) * 100, 2) for p in prop[:3]] == [52.50, 26.25, 11.25]
    assert round(float(cum[2]) * 100, 2) == 90.00, "three components give 90%"
    assert int((lam > 1).sum()) == 2, "Kaiser's criterion keeps two"

    print(f"  2.7: three components retain {cum[2]:.1%}; "
          f"Kaiser (lambda>1) keeps {int((lam > 1).sum())}")


def practice_3_eigenvalues():
    """Practice Problem 3: twelve eigenvalues, 90% at five components."""
    lam = np.array([5.4, 2.8, 1.6, 1.1, 0.8, 0.4, 0.3, 0.2, 0.2, 0.1, 0.1, 0.0])
    assert round(float(lam.sum()), 4) == 13.0
    cum = np.cumsum(lam) / lam.sum()

    k90 = int(np.searchsorted(cum, 0.90 - 1e-9) + 1)
    assert k90 == 5, k90
    assert round(float(cum[4]) * 100, 2) == 90.00
    assert int((lam > 1).sum()) == 4, "Kaiser keeps four, retaining 83.85%"
    assert round(float(cum[3]) * 100, 2) == 83.85

    print(f"  Practice 3: 90% at k={k90}; Kaiser gives k=4 at {cum[3]:.2%}")
    print(f"       the two rules DISAGREE, which is normal")


def pca_needs_standardising():
    """Section 2.7: without standardising, PC1 is just the biggest-variance column."""
    X, _ = load_iris(return_X_y=True)
    X_mixed = X.copy()
    X_mixed[:, 0] *= 10000                     # pretend one column is in rupees

    raw = PCA(n_components=2).fit(X_mixed)
    # PC1 is now dominated by that one column.
    assert abs(raw.components_[0][0]) > 0.99, \
        "unstandardised PCA just picks the largest-variance column"
    assert raw.explained_variance_ratio_[0] > 0.999

    std = PCA(n_components=2).fit(StandardScaler().fit_transform(X_mixed))
    assert abs(std.components_[0][0]) < 0.9, "standardised, PC1 blends the columns"

    print(f"  standardising: raw PC1 loading on the inflated column "
          f"{abs(raw.components_[0][0]):.4f} ({raw.explained_variance_ratio_[0]:.4%} "
          f"of variance)")
    print(f"       standardised: {abs(std.components_[0][0]):.4f} -- a real combination")


def pca_costs_interpretability():
    """Section 2.7: components are combinations, so no component is 'age'."""
    X, y = load_iris(return_X_y=True)
    Xs = StandardScaler().fit_transform(X)
    p = PCA(n_components=2).fit(Xs)

    names = load_iris().feature_names
    for i, comp in enumerate(p.components_, 1):
        terms = " ".join(f"{c:+.3f}*{n.split(' ')[0][:5]}" for c, n in zip(comp, names))
        print(f"    PC{i} = {terms}")

    assert all(abs(c).min() > 0.01 for c in p.components_), \
        "EVERY original attribute contributes to every component"
    assert p.explained_variance_ratio_.sum() > 0.95

    print(f"       {p.explained_variance_ratio_.sum():.2%} of variance in 2 of 4 "
          f"dimensions -- but nothing is 'petal length' any more")


def main():
    print("Experiment 4 -- Feature selection and PCA")
    # Step 1: Rank the attributes by information gain
    filter_ranking()
    # Step 2: Select a subset with a wrapper
    wrapper_selection()
    # Step 3: Read the worked example's eigenvalues
    unit2_eigenvalue_table()
    # Step 4: Count the components for 90% of the variance
    practice_3_eigenvalues()
    # Step 5: Standardise before PCA
    pca_needs_standardising()
    # Step 6: See what PCA costs in interpretability
    pca_costs_interpretability()
    print("  all Unit 2 dimensionality claims verified")


if __name__ == "__main__":
    main()

5. Execution and Results

In WEKA, from the command line, 04_feature_selection_weka.sh:

OUTPUT


$ java weka.attributeSelection.InfoGainAttributeEval -s weka.attributeSelection.Ranker -i data/iris.arff


=== Attribute Selection on all input data ===

Search Method:
    Attribute ranking.

Attribute Evaluator (supervised, Class (nominal): 5 class):
    Information Gain Ranking Filter

Ranked attributes:
 1.418  3 petallength
 1.378  4 petalwidth
 0.698  1 sepallength
 0.376  2 sepalwidth

Selected attributes: 3,4,1,2 : 4


$ java weka.attributeSelection.WrapperSubsetEval -B weka.classifiers.trees.J48 -F 5 -R 1 -s weka.attributeSelection.BestFirst -i data/iris.arff


=== Attribute Selection on all input data ===

Search Method:
    Best first.
    Start set: no attributes
    Search direction: forward
    Stale search after 5 node expansions
    Total number of subsets evaluated: 11
    Merit of best subset found:    0.947

Attribute Subset Evaluator (supervised, Class (nominal): 5 class):
    Wrapper Subset Evaluator
    Learning scheme: weka.classifiers.trees.J48
    Scheme options: -C 0.25 -M 2
    Subset evaluation: classification accuracy
    Number of folds for accuracy estimation: 5

Selected attributes: 4 : 1
                     petalwidth


$ java weka.filters.unsupervised.attribute.Remove -R last -i data/iris.arff -o iris-measurements.arff

$ java weka.attributeSelection.PrincipalComponents -R 0.95 -s weka.attributeSelection.Ranker -i iris-measurements.arff


=== Attribute Selection on all input data ===

Search Method:
    Attribute ranking.

Attribute Evaluator (unsupervised):
    Principal Components Attribute Transformer

Correlation matrix
  1     -0.11   0.87   0.82
 -0.11   1     -0.42  -0.36
  0.87  -0.42   1      0.96
  0.82  -0.36   0.96   1


eigenvalue  proportion  cumulative
  2.91082     0.7277      0.7277    -0.581petallength-0.566petalwidth-0.522sepallength+0.263sepalwidth
  0.92122     0.23031     0.95801   0.926sepalwidth+0.372sepallength+0.065petalwidth+0.021petallength

Eigenvectors
 V1  V2
-0.5224  0.3723 sepallength
 0.2634  0.9256 sepalwidth
-0.5813  0.0211 petallength
-0.5656  0.0654 petalwidth

Ranked attributes:
 0.2723 1 -0.581petallength-0.566petalwidth-0.522sepallength+0.263sepalwidth
 0.042  2 0.926sepalwidth+0.372sepallength+0.065petalwidth+0.021petallength

Selected attributes: 1,2 : 2


$ java weka.filters.unsupervised.attribute.PrincipalComponents -R 0.95 -c last -i data/iris.arff -o iris-pca.arff
@attribute -0.581petallength-0.566petalwidth-0.522sepallength+0.263sepalwidth numeric
@attribute 0.926sepalwidth+0.372sepallength+0.065petalwidth+0.021petallength numeric
@attribute class {Iris-setosa,Iris-versicolor,Iris-virginica}

In Python, 04_feature_selection.py:

OUTPUT

Experiment 4 -- Feature selection and PCA
  info gain ranking (WEKA: InfoGainAttributeEval + Ranker)
    0.9896  petal length (cm)
    0.9749  petal width (cm)
    0.4739  sepal length (cm)
    0.2861  sepal width (cm)
  wrapper (RFE) kept: ['petal length (cm)', 'petal width (cm)']
       4 attributes 0.9600 vs 2 attributes 0.9467
       half the features, essentially the same accuracy
  2.7: three components retain 90.0%; Kaiser (lambda>1) keeps 2
  Practice 3: 90% at k=5; Kaiser gives k=4 at 83.85%
       the two rules DISAGREE, which is normal
  standardising: raw PC1 loading on the inflated column 1.0000 (100.0000% of variance)
       standardised: 0.5211 -- a real combination
    PC1 = +0.521*sepal -0.269*sepal +0.580*petal +0.565*petal
    PC2 = +0.377*sepal +0.923*sepal +0.024*petal +0.067*petal
       95.81% of variance in 2 of 4 dimensions -- but nothing is 'petal length' any more
  all Unit 2 dimensionality claims verified

Information gain ranks petallength (1.418) and petalwidth (1.378) far above the sepal measurements; the wrapper, asking J48 itself, keeps petalwidth alone. Two principal components, with eigenvalues 2.911 and 0.921, cover 95.8% of the variance of the four measurements.

Corrected: this page gave the component's name as 0.348petallength+0.318petalwidth-0.221sepalwidth...; WEKA 3.8.7's is the one above. The script removes the class before the Select-attributes PCA: given it, that evaluator mixes the three species into the components as numbers, which the Preprocess filter does not.

The Python half ranks the iris attributes by information gain, runs PCA, and checks the cumulative variance against Unit 2's worked eigenvalue table.

RESULT

Petal length and width carry the information; two components keep 95.8% of the variance, at the cost of names that are no longer measurements.

Experiment 5 — Summarize and visualize

1. Question

Summarise a dataset and visualise it, comparing the classes.

2. Aim

Describe each attribute, and compare the classes on each.

3. Steps

In WEKA, from the command line, 05_summarize_weka.sh:

  1. Summarise every attribute (the Preprocess panel).
  2. Summarise each class on its own (filter RemoveWithValues, one species at a time).

In Python, 05_summarize.py:

  1. Load the iris data.
  2. Summarise each attribute.
  3. Compare the classes.
  4. Find the attribute that separates them.
  5. Find the correlations.

IN THE EXPLORER

  1. Preprocess → click each attribute for its statistics.
  2. Visualize All → a histogram per attribute, coloured by class.
  3. Visualize tab → the scatter-plot matrix; set Colour to the class.
  4. Click any cell to enlarge it; Jitter separates overlapping points.

Class-wise comparison: set Class as the colour, then look for an attribute whose histogram separates the colours. In iris, petallength separates setosa completely — which is the visual form of "petallength has the highest information gain".

4. Programme

In WEKA, from the command line, 05_summarize_weka.sh:

#!/usr/bin/env bash
# Experiment 5 in WEKA 3.8.7, from the command line: summarise the data.
# Each command runs the WEKA class that the Explorer runs for the same choice, and prints
# what the Explorer shows in its output pane. The click-path is on the lab page.
# Needs tools/data-science/setup_weka.sh; run as  bash 05_summarize_weka.sh
set -euo pipefail
source "$(dirname "$0")/weka.sh"

# Step 1: Summarise every attribute (the Preprocess panel)
weka weka.core.Instances $DATA/iris.arff

# Step 2: Summarise each class on its own (filter RemoveWithValues, one species at a time)
for species in 1 2 3; do
  weka weka.filters.unsupervised.instance.RemoveWithValues -C last -L $species -V -i $DATA/iris.arff -o iris-$species.arff
  weka weka.core.Instances iris-$species.arff
done

In Python, 05_summarize.py:

"""Experiment 5 — Summarize and visualize with class-wise comparison.

WEKA: the Preprocess attribute panel, Visualize All, and the Visualize tab's
scatter-plot matrix coloured by class.

This prints the numbers behind those pictures, and asserts the separation that
makes petal length the most informative attribute.
"""
import numpy as np
import pandas as pd
from sklearn.datasets import load_iris


def load():
    d = load_iris(as_frame=True)
    df = d.frame.drop(columns="target")          # the numeric label is not an attribute
    df["species"] = pd.Categorical.from_codes(d.target, d.target_names)
    return df


def attribute_panel(df):
    """What WEKA shows when you click each attribute."""
    print("  attribute summary (WEKA Preprocess panel)")
    for col in df.columns[:-1]:
        s = df[col]
        print(f"    {col:20s} min={s.min():5.2f} max={s.max():5.2f} "
              f"mean={s.mean():6.4f} sd={s.std():6.4f} missing={int(s.isna().sum())}")
    counts = df.species.value_counts().to_dict()
    assert counts == {"setosa": 50, "versicolor": 50, "virginica": 50}, counts
    print(f"    species              {counts}")


def class_wise_comparison(df):
    """The numeric form of 'colour the histogram by class'."""
    stats = df.groupby("species", observed=True).agg(["mean", "std"])
    print("\n  class-wise means")
    for col in df.columns[:-1]:
        means = [f"{stats[(col, 'mean')][s]:.3f}" for s in ["setosa", "versicolor", "virginica"]]
        print(f"    {col:20s} setosa {means[0]}  versicolor {means[1]}  virginica {means[2]}")
    return stats


def separation(df):
    """Petal length separates setosa COMPLETELY -- which is why it has the
    highest information gain in Experiment 4."""
    setosa = df[df.species == "setosa"]["petal length (cm)"]
    others = df[df.species != "setosa"]["petal length (cm)"]

    assert setosa.max() < others.min(), "setosa's petals do not overlap the others at all"
    gap = others.min() - setosa.max()
    assert round(float(gap), 2) == 1.10, gap

    # Sepal width, by contrast, overlaps heavily.
    sw_setosa = df[df.species == "setosa"]["sepal width (cm)"]
    sw_others = df[df.species != "setosa"]["sepal width (cm)"]
    assert sw_setosa.min() < sw_others.max() and sw_others.min() < sw_setosa.max(), \
        "sepal width overlaps -- which is why it ranks last"

    print(f"\n  separation: setosa petal length max {setosa.max():.1f} < "
          f"others min {others.min():.1f} -- a clean gap of {gap:.2f} cm")
    print(f"       a single threshold at 2.5 cm classifies setosa perfectly")

    threshold = 2.45
    predicted_setosa = df["petal length (cm)"] < threshold
    assert (predicted_setosa == (df.species == "setosa")).all(), \
        "one rule, 100% accurate for setosa"


def correlations(df):
    c = df[df.columns[:-1]].corr()
    petal = c.loc["petal length (cm)", "petal width (cm)"]
    assert petal > 0.95, "petal length and width are nearly redundant"
    print(f"\n  correlation: petal length vs width = {petal:.4f}")
    print(f"       nearly redundant -- feature SELECTION would drop one")


def main():
    print("Experiment 5 -- Summarize and visualize")
    # Step 1: Load the iris data
    df = load()
    # Step 2: Summarise each attribute
    attribute_panel(df)
    # Step 3: Compare the classes
    class_wise_comparison(df)
    # Step 4: Find the attribute that separates them
    separation(df)
    # Step 5: Find the correlations
    correlations(df)
    print("\n  summary statistics verified")


if __name__ == "__main__":
    main()

5. Execution and Results

In WEKA, from the command line, 05_summarize_weka.sh:

OUTPUT


$ java weka.core.Instances data/iris.arff
Relation Name:  iris
Num Instances:  150
Num Attributes: 5

     Name                      Type  Nom  Int Real     Missing      Unique  Dist
1 sepallength                Num   0%  11%  89%     0 /  0%     9 /  6%    35
2 sepalwidth                 Num   0%  19%  81%     0 /  0%     5 /  3%    23
3 petallength                Num   0%   9%  91%     0 /  0%    10 /  7%    43
4 petalwidth                 Num   0%   9%  91%     0 /  0%     2 /  1%    22
5 class                      Nom 100%   0%   0%     0 /  0%     0 /  0%     3


$ java weka.filters.unsupervised.instance.RemoveWithValues -C last -L 1 -V -i data/iris.arff -o iris-1.arff

$ java weka.core.Instances iris-1.arff
Relation Name:  iris-weka.filters.unsupervised.instance.RemoveWithValues-S0.0-Clast-L1-V
Num Instances:  50
Num Attributes: 5

     Name                      Type  Nom  Int Real     Missing      Unique  Dist
1 sepallength                Num   0%  16%  84%     0 /  0%     4 /  8%    15
2 sepalwidth                 Num   0%  14%  86%     0 /  0%     6 / 12%    16
3 petallength                Num   0%   2%  98%     0 /  0%     2 /  4%     9
4 petalwidth                 Num   0%   0% 100%     0 /  0%     2 /  4%     6
5 class                      Nom 100%   0%   0%     0 /  0%     0 /  0%     1


$ java weka.filters.unsupervised.instance.RemoveWithValues -C last -L 2 -V -i data/iris.arff -o iris-2.arff

$ java weka.core.Instances iris-2.arff
Relation Name:  iris-weka.filters.unsupervised.instance.RemoveWithValues-S0.0-Clast-L2-V
Num Instances:  50
Num Attributes: 5

     Name                      Type  Nom  Int Real     Missing      Unique  Dist
1 sepallength                Num   0%  14%  86%     0 /  0%     8 / 16%    21
2 sepalwidth                 Num   0%  18%  82%     0 /  0%     3 /  6%    14
3 petallength                Num   0%  14%  86%     0 /  0%     6 / 12%    19
4 petalwidth                 Num   0%  14%  86%     0 /  0%     2 /  4%     9
5 class                      Nom 100%   0%   0%     0 /  0%     0 /  0%     1


$ java weka.filters.unsupervised.instance.RemoveWithValues -C last -L 3 -V -i data/iris.arff -o iris-3.arff

$ java weka.core.Instances iris-3.arff
Relation Name:  iris-weka.filters.unsupervised.instance.RemoveWithValues-S0.0-Clast-L3-V
Num Instances:  50
Num Attributes: 5

     Name                      Type  Nom  Int Real     Missing      Unique  Dist
1 sepallength                Num   0%   4%  96%     0 /  0%     9 / 18%    21
2 sepalwidth                 Num   0%  24%  76%     0 /  0%     2 /  4%    13
3 petallength                Num   0%  10%  90%     0 /  0%     5 / 10%    20
4 petalwidth                 Num   0%  12%  88%     0 /  0%     3 /  6%    12
5 class                      Nom 100%   0%   0%     0 /  0%     0 /  0%     1

In Python, 05_summarize.py:

OUTPUT

Experiment 5 -- Summarize and visualize
  attribute summary (WEKA Preprocess panel)
    sepal length (cm)    min= 4.30 max= 7.90 mean=5.8433 sd=0.8281 missing=0
    sepal width (cm)     min= 2.00 max= 4.40 mean=3.0573 sd=0.4359 missing=0
    petal length (cm)    min= 1.00 max= 6.90 mean=3.7580 sd=1.7653 missing=0
    petal width (cm)     min= 0.10 max= 2.50 mean=1.1993 sd=0.7622 missing=0
    species              {'setosa': 50, 'versicolor': 50, 'virginica': 50}

  class-wise means
    sepal length (cm)    setosa 5.006  versicolor 5.936  virginica 6.588
    sepal width (cm)     setosa 3.428  versicolor 2.770  virginica 2.974
    petal length (cm)    setosa 1.462  versicolor 4.260  virginica 5.552
    petal width (cm)     setosa 0.246  versicolor 1.326  virginica 2.026

  separation: setosa petal length max 1.9 < others min 3.0 -- a clean gap of 1.10 cm
       a single threshold at 2.5 cm classifies setosa perfectly

  correlation: petal length vs width = 0.9629
       nearly redundant -- feature SELECTION would drop one

  summary statistics verified

The plots are the Explorer's to draw; the command line gives their numbers. One summary per species shows how far apart they are: setosa's petal lengths take only 9 distinct values, all small.

The Python half prints per-class means and standard deviations and confirms the separation numerically.

RESULT

Petal length separates setosa from the other two species completely.

Experiment 6 — Association rules with Apriori

1. Question

Mine association rules from transaction data with Apriori.

2. Aim

Find frequent itemsets and strong rules with Apriori, and read support, confidence and lift.

3. Steps

In WEKA, from the command line, 06_apriori_weka.sh:

  1. Mine the supermarket data with Apriori, for the 10 best rules (Associate tab).
  2. The same data, ranked by lift instead of confidence (metricType = Lift).

In Python, 06_apriori.py:

  1. Trace Apriori on the five transactions.
  2. Mine the nine transactions of Practice Problem 1.
  3. See where confidence misleads, and lift does not.
  4. Find the negative association in tea and coffee.

IN THE EXPLORER

  1. Load supermarket.arff (4,627 transactions, 217 items, sparse ARFF).
  2. Associate tab → Choose → Apriori.
  3. Click the name to set parameters:
Parameter Meaning Typical
lowerBoundMinSupport Minimum support 0.1
upperBoundMinSupport Starting support; WEKA works downwards 1.0
delta Step by which support is reduced 0.05
metricType Confidence / Lift / Leverage / Conviction Confidence
minMetric Threshold for that metric 0.9
numRules How many to report 10
car Class association rules only False

WEKA's Apriori works downwards from upperBoundMinSupport, reducing by delta until it has found numRules rules or hits the lower bound. That is unusual and is worth knowing: setting numRules too low stops the search early at a high support.

4. Programme

In WEKA, from the command line, 06_apriori_weka.sh:

#!/usr/bin/env bash
# Experiment 6 in WEKA 3.8.7, from the command line: association rules with Apriori.
# Each command runs the WEKA class that the Explorer runs for the same choice, and prints
# what the Explorer shows in its output pane. The click-path is on the lab page.
# Needs tools/data-science/setup_weka.sh; run as  bash 06_apriori_weka.sh
set -euo pipefail
source "$(dirname "$0")/weka.sh"

# Step 1: Mine the supermarket data with Apriori, for the 10 best rules (Associate tab)
weka weka.associations.Apriori -N 10 -T 0 -C 0.9 -D 0.05 -U 1.0 -M 0.1 -t $DATA/supermarket.arff

# Step 2: The same data, ranked by lift instead of confidence (metricType = Lift)
weka weka.associations.Apriori -N 10 -T 1 -C 1.1 -D 0.05 -U 1.0 -M 0.1 -t $DATA/supermarket.arff

In Python, 06_apriori.py:

"""Experiment 6 — Association rules with Apriori.

WEKA equivalent: Associate tab -> Apriori (see lab.md).

This script reproduces the hand-traced examples from Unit 3 EXACTLY, so the
notes and the code check each other. Every assertion below corresponds to a
number written out in notes/sem-4/course-8-data-mining/unit-3.md.

Run:  python3 tools/run_data_labs.py     (or this file directly)
"""
import pandas as pd
from mlxtend.frequent_patterns import apriori, association_rules
from mlxtend.preprocessing import TransactionEncoder


def frequent_itemsets(transactions, min_support):
    """One-hot encode the baskets and run Apriori."""
    te = TransactionEncoder()
    frame = pd.DataFrame(te.fit(transactions).transform(transactions),
                         columns=te.columns_)
    return apriori(frame, min_support=min_support, use_colnames=True)


def as_dict(itemsets, n):
    """{frozenset -> support COUNT}, which is what the notes tabulate."""
    return {frozenset(r.itemsets): round(r.support * n) for r in itemsets.itertuples()}


def unit3_main_trace():
    """Unit 3 section 3.4: five transactions, minsup count 3 (60%)."""
    T = [list("ACD"), list("BCE"), list("ABCE"), list("BE"), list("ABCE")]
    n = len(T)

    items = frequent_itemsets(T, 3 / n)
    found = as_dict(items, n)

    expected = {
        frozenset("A"): 3, frozenset("B"): 4, frozenset("C"): 4, frozenset("E"): 4,
        frozenset("AC"): 3, frozenset("BC"): 3, frozenset("BE"): 4, frozenset("CE"): 3,
        frozenset("BCE"): 3,
    }
    assert found == expected, f"\n got      {found}\n expected {expected}"
    assert len(found) == 9, "the notes claim nine frequent itemsets"
    assert frozenset("D") not in found, "{D} has support 1 and must be pruned"

    # Rule generation from {B,C,E} with minconf 0.8
    rules = association_rules(items, metric="confidence", min_threshold=0.8)
    strong = {
        (frozenset(r.antecedents), frozenset(r.consequents)): (round(r.confidence, 4),
                                                               round(r.lift, 4))
        for r in rules.itertuples()
        if frozenset(r.antecedents) | frozenset(r.consequents) == frozenset("BCE")
    }
    assert strong == {
        (frozenset("BC"), frozenset("E")): (1.0, 1.25),
        (frozenset("CE"), frozenset("B")): (1.0, 1.25),
    }, strong

    print(f"  3.4 trace: {len(found)} frequent itemsets, "
          f"{len(strong)} strong rules from {{B,C,E}} -- matches the notes")
    return found


def unit3_practice_1():
    """Unit 3 Practice Problem 1: nine transactions, minsup count 2, minconf 0.7."""
    T = [list("ABE"), list("BD"), list("BC"), list("ABD"), list("AC"),
         list("BC"), list("AC"), list("ABCE"), list("ABC")]
    n = len(T)

    items = frequent_itemsets(T, 2 / n)
    found = as_dict(items, n)

    expected = {
        frozenset("A"): 6, frozenset("B"): 7, frozenset("C"): 6,
        frozenset("D"): 2, frozenset("E"): 2,
        frozenset("AB"): 4, frozenset("AC"): 4, frozenset("AE"): 2,
        frozenset("BC"): 4, frozenset("BD"): 2, frozenset("BE"): 2,
        frozenset("ABC"): 2, frozenset("ABE"): 2,
    }
    assert found == expected, f"\n got      {found}\n expected {expected}"
    assert len(found) == 13, "the notes claim thirteen frequent itemsets"

    # {A,D}, {C,D}, {C,E}, {D,E} are infrequent, so every superset is pruned.
    for gone in ("AD", "CD", "CE", "DE", "ABD", "ACE", "BCE", "BCD", "BDE", "ABCE"):
        assert frozenset(gone) not in found, f"{{{gone}}} must not be frequent"

    rules = association_rules(items, metric="confidence", min_threshold=0.7)

    def summarise(pred):
        return sorted((sorted(r.antecedents), sorted(r.consequents),
                       round(r.confidence, 4), round(r.lift, 4))
                      for r in rules.itertuples() if pred(r))

    # The question asks for rules from the LARGEST frequent itemsets, which is
    # what the notes tabulate: the two 3-itemsets.
    from_3 = summarise(lambda r: len(frozenset(r.antecedents) | frozenset(r.consequents)) == 3)
    assert from_3 == [
        (["A", "E"], ["B"], 1.0, 1.2857),
        (["B", "E"], ["A"], 1.0, 1.5),
        (["E"], ["A", "B"], 1.0, 2.25),
    ], from_3

    # No strong rule comes from {A,B,C}: every confidence there is at most 0.5.
    from_abc = [r for r in rules.itertuples()
                if frozenset(r.antecedents) | frozenset(r.consequents) == frozenset("ABC")]
    assert from_abc == [], "the notes claim {A,B,C} yields no strong rule"

    # Mining the WHOLE itemset lattice also yields three strong rules from
    # 2-itemsets. The exam question scoped itself to the largest itemsets, but
    # a real Apriori run reports these too -- worth knowing they exist.
    from_2 = summarise(lambda r: len(frozenset(r.antecedents) | frozenset(r.consequents)) == 2)
    assert from_2 == [
        (["D"], ["B"], 1.0, 1.2857),
        (["E"], ["A"], 1.0, 1.5),
        (["E"], ["B"], 1.0, 1.2857),
    ], from_2

    print(f"  Practice 1: {len(found)} frequent itemsets, {len(from_3)} strong "
          f"rules from the 3-itemsets (+{len(from_2)} from 2-itemsets)")


def confidence_is_misleading():
    """Unit 3 section 3.2: the cricket / data science example.

    Support 0.72 and confidence 0.90 look excellent; lift is exactly 1.00,
    meaning the two are INDEPENDENT and the rule is worthless.
    """
    n = 1000
    T = ([list("cd")] * 720 + [list("c")] * 80
         + [list("d")] * 180 + [list("x")] * 20)
    assert len(T) == n

    items = frequent_itemsets(T, 0.1)
    rules = association_rules(items, metric="confidence", min_threshold=0.5)
    r = next(x for x in rules.itertuples()
             if frozenset(x.antecedents) == {"c"} and frozenset(x.consequents) == {"d"})

    assert round(r.support, 4) == 0.72
    assert round(r.confidence, 4) == 0.90
    assert round(r.lift, 4) == 1.0, "lift must be exactly 1 -- independent"

    print(f"  3.2 trap: support {r.support:.2f}, confidence {r.confidence:.2f}, "
          f"lift {r.lift:.2f} -- high confidence, ZERO interest")


def tea_and_coffee():
    """Unit 3 Practice Problem 3: a NEGATIVE association hiding behind
    respectable support and confidence."""
    both, tea_only, coffee_only = 400, 200, 350
    neither = 1000 - both - tea_only - coffee_only
    assert neither == 50

    T = ([["tea", "coffee"]] * both + [["tea"]] * tea_only
         + [["coffee"]] * coffee_only + [["water"]] * neither)

    items = frequent_itemsets(T, 0.05)
    rules = association_rules(items, metric="support", min_threshold=0.05)
    r = next(x for x in rules.itertuples()
             if frozenset(x.antecedents) == {"tea"} and frozenset(x.consequents) == {"coffee"})

    assert round(r.support, 4) == 0.40
    assert round(r.confidence, 4) == 0.6667
    assert round(r.lift, 4) == 0.8889, "lift below 1 -- a NEGATIVE association"
    assert round(r.leverage, 4) == -0.05, "leverage agrees: negative"

    print(f"  Practice 3: confidence {r.confidence:.4f} looks fine, but lift "
          f"{r.lift:.4f} < 1 -- tea drinkers are LESS likely to drink coffee")


def main():
    print("Experiment 6 -- Apriori (mlxtend)")
    # Step 1: Trace Apriori on the five transactions
    unit3_main_trace()
    # Step 2: Mine the nine transactions of Practice Problem 1
    unit3_practice_1()
    # Step 3: See where confidence misleads, and lift does not
    confidence_is_misleading()
    # Step 4: Find the negative association in tea and coffee
    tea_and_coffee()
    print("  all Unit 3 hand calculations reproduced")


if __name__ == "__main__":
    main()

5. Execution and Results

In WEKA, from the command line, 06_apriori_weka.sh:

OUTPUT


$ java weka.associations.Apriori -N 10 -T 0 -C 0.9 -D 0.05 -U 1.0 -M 0.1 -t data/supermarket.arff

Apriori
=======

Minimum support: 0.15 (694 instances)
Minimum metric <confidence>: 0.9
Number of cycles performed: 17

Generated sets of large itemsets:

Size of set of large itemsets L(1): 44

Size of set of large itemsets L(2): 380

Size of set of large itemsets L(3): 910

Size of set of large itemsets L(4): 633

Size of set of large itemsets L(5): 105

Size of set of large itemsets L(6): 1

Best rules found:

 1. biscuits=t frozen foods=t fruit=t total=high 788 ==> bread and cake=t 723    <conf:(0.92)> lift:(1.27) lev:(0.03) [155] conv:(3.35)
 2. baking needs=t biscuits=t fruit=t total=high 760 ==> bread and cake=t 696    <conf:(0.92)> lift:(1.27) lev:(0.03) [149] conv:(3.28)
 3. baking needs=t frozen foods=t fruit=t total=high 770 ==> bread and cake=t 705    <conf:(0.92)> lift:(1.27) lev:(0.03) [150] conv:(3.27)
 4. biscuits=t fruit=t vegetables=t total=high 815 ==> bread and cake=t 746    <conf:(0.92)> lift:(1.27) lev:(0.03) [159] conv:(3.26)
 5. party snack foods=t fruit=t total=high 854 ==> bread and cake=t 779    <conf:(0.91)> lift:(1.27) lev:(0.04) [164] conv:(3.15)
 6. biscuits=t frozen foods=t vegetables=t total=high 797 ==> bread and cake=t 725    <conf:(0.91)> lift:(1.26) lev:(0.03) [151] conv:(3.06)
 7. baking needs=t biscuits=t vegetables=t total=high 772 ==> bread and cake=t 701    <conf:(0.91)> lift:(1.26) lev:(0.03) [145] conv:(3.01)
 8. biscuits=t fruit=t total=high 954 ==> bread and cake=t 866    <conf:(0.91)> lift:(1.26) lev:(0.04) [179] conv:(3)
 9. frozen foods=t fruit=t vegetables=t total=high 834 ==> bread and cake=t 757    <conf:(0.91)> lift:(1.26) lev:(0.03) [156] conv:(3)
10. frozen foods=t fruit=t total=high 969 ==> bread and cake=t 877    <conf:(0.91)> lift:(1.26) lev:(0.04) [179] conv:(2.92)

=== Evaluation ===

Elapsed time: 2.441s


$ java weka.associations.Apriori -N 10 -T 1 -C 1.1 -D 0.05 -U 1.0 -M 0.1 -t data/supermarket.arff

Apriori
=======

Minimum support: 0.35 (1619 instances)
Minimum metric <lift>: 1.1
Number of cycles performed: 13

Generated sets of large itemsets:

Size of set of large itemsets L(1): 22

Size of set of large itemsets L(2): 36

Size of set of large itemsets L(3): 3

Best rules found:

 1. fruit=t 2962 ==> bread and cake=t vegetables=t 1791    conf:(0.6) < lift:(1.22)> lev:(0.07) [319] conv:(1.27)
 2. bread and cake=t vegetables=t 2298 ==> fruit=t 1791    conf:(0.78) < lift:(1.22)> lev:(0.07) [319] conv:(1.63)
 3. vegetables=t 2961 ==> bread and cake=t fruit=t 1791    conf:(0.6) < lift:(1.2)> lev:(0.07) [303] conv:(1.26)
 4. bread and cake=t fruit=t 2325 ==> vegetables=t 1791    conf:(0.77) < lift:(1.2)> lev:(0.07) [303] conv:(1.56)
 5. baking needs=t 2795 ==> margarine=t 1645    conf:(0.59) < lift:(1.19)> lev:(0.06) [262] conv:(1.23)
 6. margarine=t 2288 ==> baking needs=t 1645    conf:(0.72) < lift:(1.19)> lev:(0.06) [262] conv:(1.41)
 7. frozen foods=t 2717 ==> biscuits=t 1810    conf:(0.67) < lift:(1.18)> lev:(0.06) [280] conv:(1.31)
 8. biscuits=t 2605 ==> frozen foods=t 1810    conf:(0.69) < lift:(1.18)> lev:(0.06) [280] conv:(1.35)
 9. vegetables=t 2961 ==> fruit=t 2207    conf:(0.75) < lift:(1.16)> lev:(0.07) [311] conv:(1.41)
10. fruit=t 2962 ==> vegetables=t 2207    conf:(0.75) < lift:(1.16)> lev:(0.07) [311] conv:(1.41)

=== Evaluation ===

Elapsed time: 0.744s

In Python, 06_apriori.py:

OUTPUT

Experiment 6 -- Apriori (mlxtend)
  3.4 trace: 9 frequent itemsets, 2 strong rules from {B,C,E} -- matches the notes
  Practice 1: 13 frequent itemsets, 3 strong rules from the 3-itemsets (+3 from 2-itemsets)
  3.2 trap: support 0.72, confidence 0.90, lift 1.00 -- high confidence, ZERO interest
  Practice 3: confidence 0.6667 looks fine, but lift 0.8889 < 1 -- tea drinkers are LESS likely to drink coffee
  all Unit 3 hand calculations reproduced

Every one of the ten best rules by confidence ends in bread and cake: with confidence 0.91–0.92 and lift 1.26–1.27, a large basket that holds biscuits and fruit almost always holds bread too. Ranked by lift instead, the rules change — fruit with bread and cake and vegetables comes first, at lift 1.22 but confidence only 0.6.

The Python half uses mlxtend and reproduces Unit 3 §3.4's trace exactly — the same five transactions, minsup 0.6, giving the nine frequent itemsets and the two strong rules {B,C}→{E} and {C,E}→{B}, each with confidence 1.00 and lift 1.25. It also runs Unit 3's Practice Problem 1 and asserts all thirteen frequent itemsets and all three strong rules.

RESULT

On the supermarket data the strongest rules predict bread and cake; Unit 3's hand traces are reproduced exactly.

Experiment 7 — Multilevel association rules

1. Question

Mine multilevel association rules over a product hierarchy.

2. Aim

Mine rules at more than one level of a hierarchy, and drop the redundant ones.

3. Steps

  1. Try one support threshold at every level.
  2. Lower the threshold at the deeper levels.
  3. Drop a rule its ancestor already explains.
  4. Check an ancestor's support against its children's.

IN THE EXPLORER

WEKA has no built-in multilevel association miner. Say so — it is the honest answer and the examiner knows it. The standard approach is to encode the hierarchy into the data:

  1. Add ancestor attributes to each transaction: a basket containing amul_milk also gets milk and dairy.

  2. Run Apriori on the extended data.

  3. Filter out redundant ancestor rules afterwards (Unit 3 §3.9).

Alternatively, run Apriori separately at each level with a different minsup per level — reduced support, since one threshold cannot serve both the leaf and the root.

4. Programme

"""Experiment 7 — Multilevel (generalized) association rules.

WEKA has NO built-in multilevel miner (see lab.md). The standard technique is
to ADD ANCESTOR ITEMS to each transaction and mine the extended data, then
discard redundant descendant rules afterwards.

Implements Unit 3 section 3.9: the uniform-support dilemma, reduced support,
and the redundant-ancestor test.
"""
import pandas as pd
from mlxtend.frequent_patterns import apriori, association_rules
from mlxtend.preprocessing import TransactionEncoder

# leaf -> (category, department)
TAXONOMY = {
    "amul_milk":       ("milk", "dairy"),
    "nandini_milk":    ("milk", "dairy"),
    "heritage_milk":   ("milk", "dairy"),
    "amul_cheese":     ("cheese", "dairy"),
    "britannia_bread": ("bread", "bakery"),
    "croissant":       ("pastry", "bakery"),
}

# Built so the redundancy test actually DISCRIMINATES:
#   amul_milk     6 baskets, 4 with bread  -> confidence 0.667
#   nandini_milk  6 baskets, 4 with bread  -> confidence 0.667
#   heritage_milk 4 baskets, 4 with bread  -> confidence 1.000
#   milk overall 16 baskets, 12 with bread -> confidence 0.750
# so the first two are redundant (deviation 0.083) and the third is not (0.25).
TRANSACTIONS = (
    [["amul_milk", "britannia_bread"]] * 4
    + [["amul_milk", "amul_cheese"]] * 2
    + [["nandini_milk", "britannia_bread"]] * 4
    + [["nandini_milk", "croissant"]] * 2
    + [["heritage_milk", "britannia_bread"]] * 4
    + [["amul_cheese", "croissant"]] * 2
    + [["amul_cheese", "britannia_bread"]] * 2
)


def expand(transaction, levels=(0, 1, 2)):
    """Add ancestor items. 0 = leaves, 1 = category, 2 = department."""
    out = set()
    for item in transaction:
        if 0 in levels:
            out.add(item)
        for lv in (1, 2):
            if lv in levels:
                out.add(TAXONOMY[item][lv - 1])
    return sorted(out)


def at_level(level):
    return [expand(t, levels=(level,)) for t in TRANSACTIONS]


def mine(transactions, min_support, min_confidence):
    te = TransactionEncoder()
    frame = pd.DataFrame(te.fit(transactions).transform(transactions),
                         columns=te.columns_)
    items = apriori(frame, min_support=min_support, use_colnames=True)
    if items.empty:
        return items, pd.DataFrame()
    rules = association_rules(items, metric="confidence", min_threshold=min_confidence)
    return items, rules


def confidence(antecedent, consequent, transactions):
    """Computed directly from the baskets, so nothing is hidden in a library."""
    a = sum(1 for t in transactions if antecedent in t)
    both = sum(1 for t in transactions if antecedent in t and consequent in t)
    return both / a if a else 0.0, a, both


def uniform_support_dilemma():
    """Section 3.9: ONE threshold cannot serve both levels."""
    leaf_items, _ = mine(at_level(0), 0.40, 0.5)
    leaf_pairs = [i for i in leaf_items.itemsets if len(i) >= 2]
    assert leaf_pairs == [], "at 40%, no individual PRODUCT pair qualifies"

    dept_items, _ = mine(at_level(2), 0.40, 0.5)
    dept_pairs = [i for i in dept_items.itemsets if len(i) >= 2]
    assert dept_pairs, "but dairy+bakery clears 40% comfortably"

    print(f"  uniform support 40%: {len(leaf_pairs)} product pairs, "
          f"{len(dept_pairs)} department pairs")
    print(f"       set it high and the leaves vanish; set it low and the top")
    print(f"       floods you -- which is why REDUCED support exists")


def reduced_support():
    """A lower threshold at deeper levels."""
    for name, level, minsup in [("department", 2, 0.40),
                                ("category", 1, 0.25),
                                ("product", 0, 0.15)]:
        items, rules = mine(at_level(level), minsup, 0.5)
        pairs = [tuple(sorted(i)) for i in items.itemsets if len(i) >= 2]
        print(f"  {name:11s} minsup {minsup:.0%}: {len(items):2d} itemsets, "
              f"{len(pairs)} of size >= 2, {len(rules)} rules")
        assert len(items) > 0, f"{name} level found nothing"


def redundant_ancestor_rule():
    """Section 3.9: report a descendant rule only when it DEVIATES.

    A descendant rule whose confidence matches the ancestor's tells you nothing
    new -- it is the EXPECTED consequence of the ancestor rule.
    """
    mixed = [expand(t) for t in TRANSACTIONS]        # leaves AND ancestors

    anc_conf, anc_n, anc_both = confidence("milk", "bread", mixed)
    assert (anc_n, anc_both) == (16, 12), (anc_n, anc_both)
    assert round(anc_conf, 4) == 0.75, anc_conf

    print(f"\n  ancestor: milk -> bread, {anc_both}/{anc_n} = {anc_conf:.4f}")
    print(f"  descendants (tolerance 0.10):")

    TOLERANCE = 0.10
    verdicts = {}
    for brand in ["amul_milk", "nandini_milk", "heritage_milk"]:
        conf, n, both = confidence(brand, "bread", mixed)
        deviation = abs(conf - anc_conf)
        redundant = deviation <= TOLERANCE
        verdicts[brand] = redundant
        print(f"    {brand:14s} -> bread: {both}/{n} = {conf:.4f}, "
              f"deviation {deviation:.4f} -> "
              f"{'REDUNDANT, suppress' if redundant else 'DEVIATES, report it'}")

    # The demonstration must DISCRIMINATE, or it proves nothing.
    assert verdicts["amul_milk"] is True
    assert verdicts["nandini_milk"] is True
    assert verdicts["heritage_milk"] is False
    assert any(verdicts.values()) and not all(verdicts.values()), \
        "the test must both suppress and report, or it demonstrates nothing"

    print(f"       two suppressed, one reported -- heritage_milk buyers really")
    print(f"       do buy bread more often than milk buyers in general")


def ancestor_support_is_at_least_the_sum():
    """Section 3.9: an ancestor's support is AT LEAST the sum of its
    descendants' -- at least, not equal, because one basket containing two
    brands of milk counts twice at the leaf level and once at 'milk'."""
    mixed = [expand(t) for t in TRANSACTIONS]
    n = len(mixed)

    milk = sum(1 for t in mixed if "milk" in t) / n
    brands = sum(sum(1 for t in mixed if b in t) for b in
                 ["amul_milk", "nandini_milk", "heritage_milk"]) / n

    assert milk <= brands + 1e-12, "the ancestor cannot exceed the leaf sum"
    assert milk >= max(sum(1 for t in mixed if b in t) / n for b in
                       ["amul_milk", "nandini_milk", "heritage_milk"])

    print(f"\n  monotonicity: support(milk) = {milk:.4f}, "
          f"sum of brand supports = {brands:.4f}")
    print(f"       so an infrequent ancestor prunes its whole subtree")


def main():
    print("Experiment 7 -- Multilevel association rules")
    # Step 1: Try one support threshold at every level
    uniform_support_dilemma()
    # Step 2: Lower the threshold at the deeper levels
    reduced_support()
    # Step 3: Drop a rule its ancestor already explains
    redundant_ancestor_rule()
    # Step 4: Check an ancestor's support against its children's
    ancestor_support_is_at_least_the_sum()
    print("\n  multilevel mining and the redundancy test verified")


if __name__ == "__main__":
    main()

5. Execution and Results

OUTPUT

Experiment 7 -- Multilevel association rules
/tmp/claude-0/-home-user-planning-for-future/f2d15764-a81e-5002-9407-be4e7330bada/scratchpad/labenv/lib/python3.11/site-packages/mlxtend/frequent_patterns/association_rules.py:184: RuntimeWarning: invalid value encountered in divide
  cert_metric = np.where(certainty_denom == 0, 0, certainty_num / certainty_denom)
  uniform support 40%: 0 product pairs, 1 department pairs
       set it high and the leaves vanish; set it low and the top
       floods you -- which is why REDUCED support exists
/tmp/claude-0/-home-user-planning-for-future/f2d15764-a81e-5002-9407-be4e7330bada/scratchpad/labenv/lib/python3.11/site-packages/mlxtend/frequent_patterns/association_rules.py:184: RuntimeWarning: invalid value encountered in divide
  cert_metric = np.where(certainty_denom == 0, 0, certainty_num / certainty_denom)
  department  minsup 40%:  3 itemsets, 1 of size >= 2, 2 rules
  category    minsup 25%:  4 itemsets, 1 of size >= 2, 2 rules
  product     minsup 15%:  9 itemsets, 3 of size >= 2, 3 rules

  ancestor: milk -> bread, 12/16 = 0.7500
  descendants (tolerance 0.10):
    amul_milk      -> bread: 4/6 = 0.6667, deviation 0.0833 -> REDUNDANT, suppress
    nandini_milk   -> bread: 4/6 = 0.6667, deviation 0.0833 -> REDUNDANT, suppress
    heritage_milk  -> bread: 4/4 = 1.0000, deviation 0.2500 -> DEVIATES, report it
       two suppressed, one reported -- heritage_milk buyers really
       do buy bread more often than milk buyers in general

  monotonicity: support(milk) = 0.8000, sum of brand supports = 0.8000
       so an infrequent ancestor prunes its whole subtree

  multilevel mining and the redundancy test verified

There is no _weka.sh for this experiment: there is no WEKA class to run.

07_multilevel.py builds a small product taxonomy, expands each transaction with its ancestors, mines at two levels with different thresholds, and demonstrates the redundancy test: a descendant rule is reported only when its confidence deviates from what the ancestor rule predicts.

RESULT

One support threshold cannot serve every level; reduced support finds the deeper rules, and the redundancy test keeps only those that add something.

Experiment 8 — K-Means clustering

1. Question

Cluster a dataset with K-Means, and compare the clusters with the classes.

2. Aim

Run K-Means in WEKA, read its centroids and error, and see the seed change the answer.

3. Steps

In WEKA, from the command line, 08_kmeans_weka.sh:

  1. Cluster iris into 3, ignoring the class, then compare with it (SimpleKMeans, classes to clusters).
  2. The same with another seed, which can change the answer (seed = 31). Most.

In Python, 08_kmeans.py:

  1. Run K-Means by hand on the eight 1-D points.
  2. Run it on the eight 2-D points of Practice Problem 1.
  3. Check that scikit-learn agrees.
  4. See the starting centroids change the answer.
  5. Choose k by the elbow and the silhouette.

IN THE EXPLORER

  1. Load iris.arff.
  2. Preprocess → remove the class attribute (filters/unsupervised/attribute/Remove, attributeIndices = last). Clustering is unsupervised — leaving the class in is a form of leakage.

  3. Cluster tab → Choose → SimpleKMeans - numClusters = 3 - distanceFunction = EuclideanDistance - seed = 10 (changing it changes the result — that is §5.2's weakness 2)

  4. Cluster mode → Classes to clusters evaluation (re-select the class) to see how the clusters line up with the true species.

  5. Read off: cluster centroids, Within cluster sum of squared errors, and the incorrectly clustered instance count.

On the command line, -c last does steps 2 and 4 together: the class is left out of the clustering and used to evaluate it.

4. Programme

In WEKA, from the command line, 08_kmeans_weka.sh:

#!/usr/bin/env bash
# Experiment 8 in WEKA 3.8.7, from the command line: K-Means.
# Each command runs the WEKA class that the Explorer runs for the same choice, and prints
# what the Explorer shows in its output pane. The click-path is on the lab page.
# Needs tools/data-science/setup_weka.sh; run as  bash 08_kmeans_weka.sh
set -euo pipefail
source "$(dirname "$0")/weka.sh"

# Step 1: Cluster iris into 3, ignoring the class, then compare with it (SimpleKMeans, classes to clusters)
weka weka.clusterers.SimpleKMeans -N 3 -S 10 -t $DATA/iris.arff -c last

# Step 2: The same with another seed, which can change the answer (seed = 31). Most
# seeds find the clustering above; this one stops in a worse local optimum.
weka weka.clusterers.SimpleKMeans -N 3 -S 31 -t $DATA/iris.arff -c last

In Python, 08_kmeans.py:

"""Experiment 8 — K-Means clustering.

Reproduces Unit 5 section 5.2's 1-D trace and Practice Problem 1's 2-D trace
exactly, then demonstrates the elbow and silhouette methods on iris.
"""
import numpy as np
from sklearn.cluster import KMeans
from sklearn.datasets import load_iris
from sklearn.metrics import silhouette_score, davies_bouldin_score
from sklearn.preprocessing import StandardScaler


def kmeans_by_hand(points, centroids, max_iter=100):
    """Plain Lloyd's algorithm, so every iteration can be printed and checked.

    Ties go to the LOWER-indexed centroid, which is what the notes assume.
    """
    points = np.asarray(points, dtype=float)
    centroids = np.asarray(centroids, dtype=float)
    history = []

    for _ in range(max_iter):
        d = np.linalg.norm(points[:, None] - centroids[None], axis=-1)
        labels = d.argmin(axis=1)                       # argmin breaks ties low
        new = np.array([points[labels == k].mean(axis=0) for k in range(len(centroids))])
        history.append((labels.copy(), centroids.copy()))
        if np.allclose(new, centroids):
            return labels, centroids, history
        centroids = new
    return labels, centroids, history


def wcss(points, labels, centroids):
    points = np.asarray(points, dtype=float)
    return float(sum(((points[labels == k] - centroids[k]) ** 2).sum()
                     for k in range(len(centroids))))


def unit5_one_dimensional():
    """Section 5.2: points 2,4,10,12,3,20,30,11 with c = 2, 4."""
    X = np.array([[2], [4], [10], [12], [3], [20], [30], [11]], dtype=float)
    labels, cent, hist = kmeans_by_hand(X, [[2.0], [4.0]])

    c1 = sorted(X[labels == 0].ravel().tolist())
    c2 = sorted(X[labels == 1].ravel().tolist())
    assert c1 == [2, 3, 4], c1
    assert c2 == [10, 11, 12, 20, 30], c2
    assert round(float(cent[0][0]), 4) == 3.0
    assert round(float(cent[1][0]), 4) == 16.6

    w = wcss(X, labels, cent)
    assert round(w, 2) == 289.20, w

    # The outlier 30 alone contributes 62.5% of cluster 2's error.
    c2_err = float(((X[labels == 1] - cent[1]) ** 2).sum())
    share = (30 - 16.6) ** 2 / c2_err
    assert round(c2_err, 2) == 287.20
    assert round(share * 100, 1) == 62.5

    # Convergence took three passes: two that moved, one that confirmed.
    assert len(hist) == 3, f"the notes show three iterations, got {len(hist)}"

    print(f"  5.2 1-D: C1={c1} c=3.0, C2={c2} c=16.6, WCSS={w:.1f}")
    print(f"       the single outlier 30 contributes {share:.1%} of C2's error")


def unit5_practice_1():
    """Practice Problem 1: eight 2-D points, initial centroids A and C."""
    names = list("ABCDEFGH")
    X = np.array([[2, 10], [2, 5], [8, 4], [5, 8], [7, 5], [6, 4], [1, 2], [4, 9]],
                 dtype=float)
    labels, cent, hist = kmeans_by_hand(X, [[2.0, 10.0], [8.0, 4.0]])

    c1 = sorted(names[i] for i in range(8) if labels[i] == 0)
    c2 = sorted(names[i] for i in range(8) if labels[i] == 1)
    assert c1 == ["A", "B", "D", "H"], c1
    assert c2 == ["C", "E", "F", "G"], c2

    assert [round(v, 4) for v in cent[0]] == [3.25, 8.0], cent[0]
    assert [round(v, 4) for v in cent[1]] == [5.5, 3.75], cent[1]

    w = wcss(X, labels, cent)
    assert round(w, 2) == 54.50, w

    # G(1,2) is the far point: 42.8% of the total error.
    g_err = float(((X[6] - cent[1]) ** 2).sum())
    assert round(g_err, 4) == 23.3125
    assert round(g_err / w * 100, 1) == 42.8

    assert len(hist) == 2, "the notes show convergence after two iterations"

    print(f"  Practice 1: C1={c1} at (3.25, 8.0), C2={c2} at (5.5, 3.75)")
    print(f"       WCSS={w:.2f}; G alone contributes {g_err / w:.1%}")


def sklearn_agrees():
    """scikit-learn, given the same initial centroids, must land in the same place."""
    X = np.array([[2, 10], [2, 5], [8, 4], [5, 8], [7, 5], [6, 4], [1, 2], [4, 9]],
                 dtype=float)
    km = KMeans(n_clusters=2, init=np.array([[2.0, 10.0], [8.0, 4.0]]),
                n_init=1, random_state=0).fit(X)
    assert round(float(km.inertia_), 2) == 54.50, km.inertia_
    centres = sorted(tuple(round(v, 4) for v in c) for c in km.cluster_centers_)
    assert centres == [(3.25, 8.0), (5.5, 3.75)], centres
    print(f"  sklearn KMeans: inertia {km.inertia_:.2f} -- identical")


def initialisation_matters():
    """Section 5.2 weakness 2: K-Means finds only a LOCAL optimum."""
    rng = np.random.default_rng(0)
    X = np.vstack([rng.normal([0, 0], 0.5, (40, 2)),
                   rng.normal([5, 5], 0.5, (40, 2)),
                   rng.normal([0, 5], 0.5, (40, 2))])

    # Rounded to the 2 places printed: K-Means sums in parallel threads, so two
    # runs can differ in the last bits, and an unrounded set counted the same
    # optimum twice -- 6 distinct on one run, 7 on the next. [Corrected.]
    inertias = {round(KMeans(n_clusters=3, init="random", n_init=1,
                             random_state=s).fit(X).inertia_, 2) for s in range(30)}
    assert len(inertias) > 1, "different seeds must give different local optima"

    best_single = min(inertias)
    multi = KMeans(n_clusters=3, n_init=10, random_state=0).fit(X).inertia_
    assert multi <= max(inertias) + 0.005

    print(f"  initialisation: {len(inertias)} distinct optima across 30 seeds "
          f"({min(inertias):.2f} to {max(inertias):.2f})")
    print(f"       n_init=10 finds {multi:.2f} -- which is why it is the default")


def choosing_k():
    """Elbow and silhouette on iris."""
    X, y = load_iris(return_X_y=True)
    Xs = StandardScaler().fit_transform(X)

    print("      k    WCSS  silhouette  Davies-Bouldin")
    sil = {}
    prev = None
    for k in range(2, 8):
        km = KMeans(n_clusters=k, n_init=10, random_state=0).fit(Xs)
        s = silhouette_score(Xs, km.labels_)
        db = davies_bouldin_score(Xs, km.labels_)
        sil[k] = s
        print(f"      {k}  {km.inertia_:7.2f}     {s:.4f}        {db:.4f}")
        if prev is not None:
            assert km.inertia_ < prev, "WCSS must fall monotonically with k"
        prev = km.inertia_

    best = max(sil, key=sil.get)
    assert best == 2, f"silhouette peaks at k=2 on scaled iris, got {best}"

    print(f"       silhouette peaks at k={best}, though iris has 3 species --")
    print(f"       two of them overlap, so the DATA says 2 and the LABELS say 3.")
    print(f"       That is exactly why clustering has no single right answer.")


def main():
    print("Experiment 8 -- K-Means")
    # Step 1: Run K-Means by hand on the eight 1-D points
    unit5_one_dimensional()
    # Step 2: Run it on the eight 2-D points of Practice Problem 1
    unit5_practice_1()
    # Step 3: Check that scikit-learn agrees
    sklearn_agrees()
    # Step 4: See the starting centroids change the answer
    initialisation_matters()
    # Step 5: Choose k by the elbow and the silhouette
    choosing_k()
    print("  all Unit 5 K-Means calculations reproduced")


if __name__ == "__main__":
    main()

5. Execution and Results

In WEKA, from the command line, 08_kmeans_weka.sh:

OUTPUT


$ java weka.clusterers.SimpleKMeans -N 3 -S 10 -t data/iris.arff -c last


=== Clustering stats for training data ===


kMeans
======

Number of iterations: 6
Within cluster sum of squared errors: 6.998114004826762

Initial starting points (random):

Cluster 0: 6.1,2.9,4.7,1.4
Cluster 1: 6.2,2.9,4.3,1.3
Cluster 2: 6.9,3.1,5.1,2.3

Missing values globally replaced with mean/mode

Final cluster centroids:
                           Cluster#
Attribute      Full Data          0          1          2
                 (150.0)     (61.0)     (50.0)     (39.0)
=========================================================
sepallength       5.8433     5.8885      5.006     6.8462
sepalwidth         3.054     2.7377      3.418     3.0821
petallength       3.7587     4.3967      1.464     5.7026
petalwidth        1.1987      1.418      0.244     2.0795


Clustered Instances

0       61 ( 41%)
1       50 ( 33%)
2       39 ( 26%)


Class attribute: class
Classes to Clusters:

  0  1  2  <-- assigned to cluster
  0 50  0 | Iris-setosa
 47  0  3 | Iris-versicolor
 14  0 36 | Iris-virginica

Cluster 0 <-- Iris-versicolor
Cluster 1 <-- Iris-setosa
Cluster 2 <-- Iris-virginica

Incorrectly clustered instances :   17.0     11.3333 %


$ java weka.clusterers.SimpleKMeans -N 3 -S 31 -t data/iris.arff -c last


=== Clustering stats for training data ===


kMeans
======

Number of iterations: 5
Within cluster sum of squared errors: 10.908274989622528

Initial starting points (random):

Cluster 0: 6.4,2.8,5.6,2.2
Cluster 1: 4.9,3.1,1.5,0.1
Cluster 2: 5.2,3.4,1.4,0.2

Missing values globally replaced with mean/mode

Final cluster centroids:
                           Cluster#
Attribute      Full Data          0          1          2
                 (150.0)     (96.0)     (22.0)     (32.0)
=========================================================
sepallength       5.8433     6.3146     4.7545     5.1781
sepalwidth         3.054     2.8958     2.9045     3.6313
petallength       3.7587      4.974     1.7455     1.4969
petalwidth        1.1987     1.7031     0.3364     0.2781


Clustered Instances

0       96 ( 64%)
1       22 ( 15%)
2       32 ( 21%)


Class attribute: class
Classes to Clusters:

  0  1  2  <-- assigned to cluster
  0 18 32 | Iris-setosa
 46  4  0 | Iris-versicolor
 50  0  0 | Iris-virginica

Cluster 0 <-- Iris-virginica
Cluster 1 <-- Iris-versicolor
Cluster 2 <-- Iris-setosa

Incorrectly clustered instances :   64.0     42.6667 %

In Python, 08_kmeans.py:

OUTPUT

Experiment 8 -- K-Means
  5.2 1-D: C1=[2.0, 3.0, 4.0] c=3.0, C2=[10.0, 11.0, 12.0, 20.0, 30.0] c=16.6, WCSS=289.2
       the single outlier 30 contributes 62.5% of C2's error
  Practice 1: C1=['A', 'B', 'D', 'H'] at (3.25, 8.0), C2=['C', 'E', 'F', 'G'] at (5.5, 3.75)
       WCSS=54.50; G alone contributes 42.8%
  sklearn KMeans: inertia 54.50 -- identical
  initialisation: 6 distinct optima across 30 seeds (59.52 to 554.54)
       n_init=10 finds 59.52 -- which is why it is the default
      k    WCSS  silhouette  Davies-Bouldin
      2   222.36     0.5818        0.5933
      3   139.82     0.4599        0.8336
      4   114.09     0.3869        0.8698
      5    90.81     0.3455        0.9452
      6    80.02     0.3257        1.0519
      7    71.61     0.3323        1.0654
       silhouette peaks at k=2, though iris has 3 species --
       two of them overlap, so the DATA says 2 and the LABELS say 3.
       That is exactly why clustering has no single right answer.
  all Unit 5 K-Means calculations reproduced

With seed 10 the clusters match the species but for 17 flowers (11.3%), all versicolor and virginica, with a squared error of 7.00. Seed 31 stops in a worse local optimum, error 10.91: it splits setosa in two and merges the other species. Most seeds find the first answer.

The Python half reproduces Unit 5 §5.2's 1-D trace (final centroids 3.0 and 16.6, WCSS 289.2) and Practice Problem 1's 2-D trace (centroids (3.25, 8.0) and (5.5, 3.75), WCSS 54.50), both asserted, then runs the elbow and silhouette methods on iris.

RESULT

K-Means recovers the three species with 17 errors from a good start, and finds a worse clustering from a bad one.

Experiment 9 — Hierarchical clustering and dendrograms

1. Question

Cluster a dataset hierarchically, and draw the dendrogram.

2. Aim

Build a hierarchical clustering in WEKA, and compare single and complete linkage.

3. Steps

In WEKA, from the command line, 09_hierarchical_weka.sh:

  1. Cluster iris by single linkage, and print the tree (HierarchicalClusterer, printNewick).
  2. The same by complete linkage.

In Python, 09_hierarchical.py:

  1. Build the worked example's dendrogram.
  2. Build Practice Problem 2's.
  3. Compare single and complete linkage.

IN THE EXPLORER

  1. Cluster → Choose → HierarchicalClusterer - numClusters = 3 - linkType = SINGLE / COMPLETE / AVERAGE / WARD / CENTROID / MEAN / ADJCOMPLETE / NEIGHBOR_JOINING

4. Programme

In WEKA, from the command line, 09_hierarchical_weka.sh:

#!/usr/bin/env bash
# Experiment 9 in WEKA 3.8.7, from the command line: hierarchical clustering.
# Each command runs the WEKA class that the Explorer runs for the same choice, and prints
# what the Explorer shows in its output pane. The click-path is on the lab page.
# Needs tools/data-science/setup_weka.sh; run as  bash 09_hierarchical_weka.sh
set -euo pipefail
source "$(dirname "$0")/weka.sh"

# Step 1: Cluster iris by single linkage, and print the tree (HierarchicalClusterer, printNewick)
weka weka.clusterers.HierarchicalClusterer -N 3 -L SINGLE -P -t $DATA/iris.arff -c last

# Step 2: The same by complete linkage
weka weka.clusterers.HierarchicalClusterer -N 3 -L COMPLETE -t $DATA/iris.arff -c last

In Python, 09_hierarchical.py:

"""Experiment 9 — Hierarchical clustering and dendrograms.

Reproduces Unit 5 section 5.4's worked dendrogram and Practice Problem 2
exactly, and shows how single and complete linkage differ on the same matrix.
"""
import numpy as np
from scipy.cluster.hierarchy import linkage, fcluster, dendrogram
from scipy.spatial.distance import squareform


def merge_heights(matrix, method):
    """Return the merge heights in order, from a square distance matrix."""
    Z = linkage(squareform(np.array(matrix, dtype=float), checks=False), method=method)
    return Z, [round(float(h), 4) for h in Z[:, 2]]


def unit5_worked_dendrogram():
    """Section 5.4: five points A-E, single linkage, merges at 2, 3, 4, 5."""
    labels = list("ABCDE")
    D = [[0,  2,  6, 10,  9],
         [2,  0,  5,  9,  8],
         [6,  5,  0,  4,  5],
         [10, 9,  4,  0,  3],
         [9,  8,  5,  3,  0]]

    Z, heights = merge_heights(D, "single")
    assert heights == [2.0, 3.0, 4.0, 5.0], heights

    # Cutting at 4.5 must give exactly {A,B} and {C,D,E}.
    groups = fcluster(Z, t=4.5, criterion="distance")
    clusters = sorted(sorted(labels[i] for i in range(5) if groups[i] == g)
                      for g in set(groups))
    assert clusters == [["A", "B"], ["C", "D", "E"]], clusters

    # Cutting at 3.5 gives three.
    g3 = fcluster(Z, t=3.5, criterion="distance")
    c3 = sorted(sorted(labels[i] for i in range(5) if g3[i] == g) for g in set(g3))
    assert c3 == [["A", "B"], ["C"], ["D", "E"]], c3

    # Complete linkage: SAME merge order here, but LARGER heights, because it
    # always reports the farthest pair.
    _, comp = merge_heights(D, "complete")
    assert comp == [2.0, 3.0, 5.0, 10.0], comp
    assert all(c >= s for c, s in zip(comp, heights)), \
        "complete linkage heights are never below single linkage heights"

    print(f"  5.4: single-linkage merges at {heights}; cut at 4.5 -> {clusters}")
    print(f"       complete linkage on the same matrix: {comp}")


def unit5_practice_2():
    """Practice Problem 2: P1-P5, single linkage, merges at 2, 3, 5, 6."""
    labels = ["P1", "P2", "P3", "P4", "P5"]
    D = [[0,  9,  3,  6, 11],
         [9,  0,  7,  5, 10],
         [3,  7,  0,  9,  2],
         [6,  5,  9,  0,  8],
         [11, 10, 2,  8,  0]]

    Z, heights = merge_heights(D, "single")
    assert heights == [2.0, 3.0, 5.0, 6.0], heights

    # Cut just above 5 -> two clusters.
    g = fcluster(Z, t=5.5, criterion="distance")
    clusters = sorted(sorted(labels[i] for i in range(5) if g[i] == c) for c in set(g))
    assert clusters == [["P1", "P3", "P5"], ["P2", "P4"]], clusters

    # Cut just below 5 -> three, as the notes point out for the tie.
    g2 = fcluster(Z, t=4.5, criterion="distance")
    c2 = sorted(sorted(labels[i] for i in range(5) if g2[i] == c) for c in set(g2))
    assert c2 == [["P1", "P3", "P5"], ["P2"], ["P4"]], c2

    print(f"  Practice 2: merges at {heights}; cut above 5 -> {clusters}")
    print(f"       cut below 5 -> {c2} (the notes flag this tie)")


def linkage_changes_the_answer():
    """Section 5.4: single linkage CHAINS; complete linkage does not.

    Two compact blobs joined by a thin bridge of points. Single linkage follows
    the bridge and merges them; complete linkage refuses.
    """
    left = np.array([[0.0, 0.0], [0.4, 0.2], [0.2, 0.5], [0.5, 0.5]])
    right = np.array([[6.0, 0.0], [6.4, 0.2], [6.2, 0.5], [6.5, 0.5]])
    bridge = np.array([[1.5, 0.25], [3.0, 0.25], [4.5, 0.25]])
    X = np.vstack([left, bridge, right])

    def two_clusters(method):
        Z = linkage(X, method=method)
        g = fcluster(Z, t=2, criterion="maxclust")
        return sorted(int((g == c).sum()) for c in set(g))

    single = two_clusters("single")
    complete = two_clusters("complete")

    assert single != complete, "the two linkages must disagree on this data"
    print(f"  chaining: single linkage splits {single}, "
          f"complete linkage splits {complete}")
    print(f"       same data, same k -- the LINKAGE decides the answer")


def main():
    print("Experiment 9 -- Hierarchical clustering")
    # Step 1: Build the worked example's dendrogram
    unit5_worked_dendrogram()
    # Step 2: Build Practice Problem 2's
    unit5_practice_2()
    # Step 3: Compare single and complete linkage
    linkage_changes_the_answer()
    print("  all Unit 5 hierarchical calculations reproduced")


if __name__ == "__main__":
    main()

5. Execution and Results

In WEKA, from the command line, 09_hierarchical_weka.sh:

OUTPUT


$ java weka.clusterers.HierarchicalClusterer -N 3 -L SINGLE -P -t data/iris.arff -c last


=== Clustering stats for training data ===

Cluster 0
((((((((((((((((((((0.2:0.03254,0.2:0.03254):0.00913,(0.3:0.03254,0.3:0.03254):0.00913):0.00332,((0.2:0.02778,0.2:0.02778):0.00476,0.2:0.03254):0.01244):0,0.2:0.04498):0.0051,0.2:0.05008):0.00364,0.2:0.05371):0.00437,(0.2:0.05085,0.2:0.05085):0.00724):0.01535,(0.5:0.06731,0.4:0.06731):0.00612):0.00188,0.2:0.07531):0.00196,0.3:0.07728):0.00536,((((((0.2:0.04383,0.2:0.04383):0.00625,0.3:0.05008):0,0.1:0.05008):0.00279,(((((0.2:0.03254,0.2:0.03254):0.01129,0.2:0.04383):0.00116,0.2:0.04498):0.0051,0.2:0.05008):0.00279,((0.1:0,0.1:0):0,0.1:0):0.05287):0):0.00522,0.2:0.05808):0.01919,((0.2:0.04498,0.2:0.04498):0.01549,0.1:0.06047):0.0168):0.00536):0.00165,0.2:0.08429):0.00356,(((0.2:0.02778,0.2:0.02778):0.04371,((0.3:0.04498,0.2:0.04498):0.01394,0.4:0.05893):0.01256):0.00809,0.4:0.07958):0.00826):0.00212,0.4:0.08996):0.00321,0.6:0.09317):0.00598,(0.4:0.0678,0.4:0.0678):0.03135):0.00292,0.3:0.10206):0.01316,0.2:0.11523):0.01375,(0.2:0.12263,(0.1:0.10346,0.2:0.10346):0.01917):0.00634):0.00241,0.4:0.13139)

Cluster 2
(((((((((((((((((((((((((((((1.4:0.07344,(((1.5:0.06508,1.5:0.06508):0.00066,(1.4:0.05008,1.4:0.05008):0.01566):0.00224,1.3:0.06798):0.00546):0.00188,(1.3:0.07137,(1.3:0.05556,1.3:0.05556):0.01581):0.00395):0.00733,(1.5:0.07137,((1.4:0.04498,1.4:0.04498):0.01549,1.5:0.06047):0.01089):0.01127):0.00515,1.4:0.08779):0.00538,1.2:0.09317):0.00405,1.5:0.09722):0.0004,(1.5:0.05556,1.5:0.05556):0.04207):0.00152,(1.5:0.07344,1.6:0.07344):0.02571):0,1.6:0.09914):0.00219,1.5:0.10133):0.00073,1.6:0.10206):0.0014,(((((1.3:0.08333,1.3:0.08333):0.00613,((((1.3:0.06574,((1.3:0.05287,1.2:0.05287):0,(1.3:0.05287,(1.3:0.04498,1.3:0.04498):0.00789):0):0.01287):0.0077,(1.2:0.04498,1.2:0.04498):0.02845):0,1.2:0.07344):0.0093,(1.1:0.05287,(1.1:0.04498,1.0:0.04498):0.00789):0.02987):0.00672):0.0005,1.0:0.08996):0.00406,1.0:0.09402):0.00041,1.3:0.09443):0.00902):0.00268,1.7:0.10614):0.00342,((((((1.8:0.08784,((1.8:0.03254,1.8:0.03254):0.0254,1.8:0.05794):0.0299):0.00162,(1.9:0.08429,(1.8:0.05287,1.8:0.05287):0.03142):0.00518):0.00524,1.9:0.0947):0.01144,(2.2:0.09415,(2.1:0.04167,2.2:0.04167):0.05249):0.01199):0,(((1.8:0.07148,(1.8:0.05008,1.8:0.05008):0.02141):0.02614,(2.0:0.08504,2.0:0.08504):0.01258):0.00852,(((2.1:0.05287,2.1:0.05287):0.04475,((((2.3:0.04383,2.3:0.04383):0.03881,2.4:0.08264):0.00719,(2.3:0.07148,2.3:0.07148):0.01834):0.00487,2.5:0.0947):0.00292):0.00534,2.1:0.10296):0.00318):0):0.00129,2.1:0.10743):0.00214):0.00446,((2.5:0.08983,(2.4:0.06047,2.3:0.06047):0.02935):0.01175,2.3:0.10158):0.01245):0.01212,1.4:0.12614):0.00283,1.4:0.12897):0.00054,1.5:0.12951):0.00514,(((1.9:0,1.9:0):0.08779,2.0:0.08779):0.01089,2.0:0.09869):0.03597):0.01023,((1.5:0.09869,1.3:0.09869):0.00264,1.5:0.10133):0.04356):0.00338,(((2.1:0.09869,2.0:0.09869):0.02337,2.3:0.12206):0.01586,((1.8:0.07344,1.9:0.07344):0.05554,(1.8:0.12263,1.6:0.12263):0.00634):0.00895):0.01034):0.00275,1.8:0.15102):0.00299,2.3:0.15401):0.00606,(((1.0:0.05008,1.0:0.05008):0.04555,1.1:0.09562):0.03389,1.0:0.12951):0.03056):0.00969,1.0:0.16976):0.00916,2.4:0.17892):0.01985,2.5:0.19878):0.00086,1.7:0.19964):0.02884,(2.2:0.11232,2.0:0.11232):0.11615)

Clustered Instances

0       49 ( 33%)
1        1 (  1%)
2      100 ( 67%)


Class attribute: class
Classes to Clusters:

  0  1  2  <-- assigned to cluster
 49  1  0 | Iris-setosa
  0  0 50 | Iris-versicolor
  0  0 50 | Iris-virginica

Cluster 0 <-- Iris-setosa
Cluster 1 <-- No class
Cluster 2 <-- Iris-versicolor

Incorrectly clustered instances :   51.0     34      %


$ java weka.clusterers.HierarchicalClusterer -N 3 -L COMPLETE -t data/iris.arff -c last


=== Clustering stats for training data ===

Clustered Instances

0       50 ( 33%)
1       66 ( 44%)
2       34 ( 23%)


Class attribute: class
Classes to Clusters:

  0  1  2  <-- assigned to cluster
 50  0  0 | Iris-setosa
  0 49  1 | Iris-versicolor
  0 17 33 | Iris-virginica

Cluster 0 <-- Iris-setosa
Cluster 1 <-- Iris-versicolor
Cluster 2 <-- Iris-virginica

Incorrectly clustered instances :   18.0     12      %

In Python, 09_hierarchical.py:

OUTPUT

Experiment 9 -- Hierarchical clustering
  5.4: single-linkage merges at [2.0, 3.0, 4.0, 5.0]; cut at 4.5 -> [['A', 'B'], ['C', 'D', 'E']]
       complete linkage on the same matrix: [2.0, 3.0, 5.0, 10.0]
  Practice 2: merges at [2.0, 3.0, 5.0, 6.0]; cut above 5 -> [['P1', 'P3', 'P5'], ['P2', 'P4']]
       cut below 5 -> [['P1', 'P3', 'P5'], ['P2'], ['P4']] (the notes flag this tie)
  chaining: single linkage splits [4, 7], complete linkage splits [5, 6]
       same data, same k -- the LINKAGE decides the answer
  all Unit 5 hierarchical calculations reproduced

The dendrogram is the Explorer's to draw; printNewick prints the same tree as text. Single linkage chains: it puts versicolor and virginica in one cluster and leaves a single setosa flower as a cluster of its own, 51 errors (34%). Complete linkage gives three real groups, 18 errors (12%).

The Python half reproduces Unit 5 §5.4's worked dendrogram — merges at heights 2, 3, 4, 5 under single linkage — and Practice Problem 2 (heights 2, 3, 5, 6, giving {P1,P3,P5} and {P2,P4} at a cut of 5). It also shows complete linkage on the same matrix so the difference in merge heights is visible.

RESULT

Single linkage chains the two close species together; complete linkage separates all three, with 18 errors.

Experiment 10 — EM clustering

1. Question

Cluster a dataset with EM, and compare it with K-Means.

2. Aim

Fit a mixture model with EM, let it choose the number of clusters, and compare with K-Means.

3. Steps

In WEKA, from the command line, 10_em_clustering_weka.sh:

  1. Let EM choose the number of clusters by cross-validation (EM, numClusters = -1).
  2. EM with 3 clusters, to compare with K-Means.

In Python, 10_em_clustering.py:

  1. Compare soft and hard assignment.
  2. Fit elliptical clusters with EM.
  3. Choose k by BIC.
  4. See K-Means as a special case of EM.

IN THE EXPLORER

  1. Cluster → Choose → EM - numClusters = -1 → WEKA chooses k by cross-validation. That automatic selection is EM's distinctive feature in WEKA and is worth stating.

EM versus K-Means is the exam question:

K-Means EM
Assignment Hard — one cluster each Soft — a probability of each
Model Centroids A mixture of distributions
Cluster shape Spherical, equal size Elliptical, any covariance
Output Labels Labels and membership probabilities
Objective Minimise WCSS Maximise log likelihood

K-Means is in fact a limiting case of EM with spherical equal-variance Gaussians and hard assignment.

4. Programme

In WEKA, from the command line, 10_em_clustering_weka.sh:

#!/usr/bin/env bash
# Experiment 10 in WEKA 3.8.7, from the command line: EM clustering.
# Each command runs the WEKA class that the Explorer runs for the same choice, and prints
# what the Explorer shows in its output pane. The click-path is on the lab page.
# Needs tools/data-science/setup_weka.sh; run as  bash 10_em_clustering_weka.sh
set -euo pipefail
source "$(dirname "$0")/weka.sh"

# Step 1: Let EM choose the number of clusters by cross-validation (EM, numClusters = -1)
weka weka.clusterers.EM -N -1 -I 100 -t $DATA/iris.arff -c last

# Step 2: EM with 3 clusters, to compare with K-Means
weka weka.clusterers.EM -N 3 -I 100 -t $DATA/iris.arff -c last

In Python, 10_em_clustering.py:

"""Experiment 10 — EM clustering, compared with K-Means.

WEKA: Cluster tab -> EM, with numClusters = -1 to let cross-validation choose k.

The point of the experiment is SOFT assignment: EM gives each point a
probability of membership in every cluster, where K-Means gives a hard label.
"""
import numpy as np
from sklearn.cluster import KMeans
from sklearn.mixture import GaussianMixture
from sklearn.datasets import load_iris
from sklearn.preprocessing import StandardScaler


def soft_versus_hard():
    """Two overlapping Gaussians: the boundary points are the interesting ones."""
    rng = np.random.default_rng(7)
    X = np.vstack([rng.normal(0.0, 1.0, (200, 1)),
                   rng.normal(3.0, 1.0, (200, 1))])

    km = KMeans(n_clusters=2, n_init=10, random_state=0).fit(X)
    em = GaussianMixture(n_components=2, random_state=0).fit(X)

    probs = em.predict_proba(X)
    confidence = probs.max(axis=1)

    # K-Means labels are 0/1 with no notion of confidence.
    assert set(np.unique(km.labels_)) == {0, 1}
    assert probs.shape == (400, 2)
    assert np.allclose(probs.sum(axis=1), 1.0), "each row is a distribution"

    uncertain = int((confidence < 0.8).sum())
    assert uncertain > 0, "overlapping Gaussians MUST produce uncertain points"

    boundary = X[confidence.argmin()][0]
    assert 0.5 < boundary < 2.5, f"the least certain point sits between the means: {boundary}"

    print(f"  soft assignment: {uncertain} of 400 points have max probability "
          f"below 0.8")
    print(f"       least certain point x={boundary:.3f}, probabilities "
          f"{np.round(probs[confidence.argmin()], 3).tolist()}")
    print(f"       K-Means would give that point a confident 0 or 1 and tell")
    print(f"       you nothing about the doubt")


def em_finds_elliptical_clusters():
    """K-Means assumes spherical clusters; EM does not -- but EM is ALSO
    initialisation-sensitive, and this example shows how to handle that.

    Two long, thin, parallel ellipses. K-Means splits along the high-variance
    x direction and cuts BOTH ellipses in half, scoring near chance. EM can
    recover them, but its default k-means initialisation reaches a bad local
    optimum on most seeds. The fix is the same as K-Means's: run several
    initialisations and KEEP THE BEST OBJECTIVE -- here the log likelihood.
    """
    rng = np.random.default_rng(0)
    a = rng.multivariate_normal([0, 0], [[9.0, 0.0], [0.0, 0.15]], 300)
    b = rng.multivariate_normal([0, 3], [[9.0, 0.0], [0.0, 0.15]], 300)
    X = np.vstack([a, b])
    truth = np.r_[np.zeros(300), np.ones(300)]

    def accuracy(labels):
        agree = (labels == truth).mean()
        return max(agree, 1 - agree)            # cluster labels are arbitrary

    km_acc = accuracy(KMeans(n_clusters=2, n_init=10, random_state=0).fit_predict(X))
    assert km_acc < 0.60, f"K-Means should be near chance here, got {km_acc}"

    # EM's DEFAULT initialisation is k-means, so it inherits exactly the blind
    # spot we just demonstrated -- and lands in the same bad optimum on every
    # seed. That is worth seeing before fixing it.
    default = [GaussianMixture(n_components=2, covariance_type="full",
                               random_state=s, n_init=1).fit(X) for s in range(4)]
    assert len({round(g.score(X), 4) for g in default}) == 1, \
        "k-means initialisation gives EM the same (bad) answer every time"
    assert accuracy(default[0].predict(X)) < 0.60

    # Initialising from random data points instead lets EM escape it.
    runs = []
    for seed in range(8):
        g = GaussianMixture(n_components=2, covariance_type="full",
                            init_params="random_from_data",
                            random_state=seed, n_init=1).fit(X)
        runs.append((g.score(X), accuracy(g.predict(X))))

    logliks = [r[0] for r in runs]
    assert max(logliks) - min(logliks) > 0.1, \
        "EM must reach genuinely different local optima across seeds"

    # The decisive point: the run with the BEST log likelihood is also the
    # accurate one. Selecting by the objective finds the right answer without
    # ever consulting the labels -- which is what makes it usable in practice.
    best_loglik, best_acc = max(runs, key=lambda r: r[0])
    worst_loglik, worst_acc = min(runs, key=lambda r: r[0])
    assert best_acc > 0.95, f"the best-likelihood run should be accurate, got {best_acc}"
    assert worst_acc < 0.60, f"the worst should not be, got {worst_acc}"
    assert best_acc > km_acc

    print(f"\n  elliptical clusters: K-Means {km_acc:.4f} (near chance --")
    print(f"       it splits along x and cuts BOTH ellipses)")
    print(f"       EM with its DEFAULT k-means init: {accuracy(default[0].predict(X)):.4f} "
          f"on every seed -- it inherits the same blind spot")
    print(f"       EM across 8 seeds: log likelihood {min(logliks):.4f} to "
          f"{max(logliks):.4f}")
    print(f"       best-likelihood run  {best_loglik:.4f} -> accuracy {best_acc:.4f}")
    print(f"       worst-likelihood run {worst_loglik:.4f} -> accuracy {worst_acc:.4f}")
    print(f"       selecting by the OBJECTIVE finds the right clustering without")
    print(f"       ever looking at the labels -- so n_init matters for EM too")


def choose_k_by_bic():
    """WEKA's numClusters = -1 uses cross-validation; the usual Python
    equivalent is BIC, which penalises parameters."""
    X, _ = load_iris(return_X_y=True)
    Xs = StandardScaler().fit_transform(X)

    print("\n      k       BIC       AIC   loglik")
    scores = {}
    for k in range(1, 7):
        g = GaussianMixture(n_components=k, covariance_type="full",
                            random_state=0, n_init=3).fit(Xs)
        scores[k] = g.bic(Xs)
        print(f"      {k}  {g.bic(Xs):9.2f} {g.aic(Xs):9.2f}  {g.score(Xs):7.4f}")

    best = min(scores, key=scores.get)
    assert 1 <= best <= 6
    assert scores[best] == min(scores.values())

    print(f"       BIC is LOWEST at k={best}")
    print(f"       (lower BIC is better -- the opposite of a log likelihood,")
    print(f"        which always improves with more components)")

    # Log likelihood alone would always choose the largest k -- that is why a
    # penalty term is needed at all.
    logliks = [GaussianMixture(n_components=k, covariance_type="full",
                               random_state=0, n_init=3).fit(Xs).score(Xs)
               for k in range(1, 7)]
    assert logliks == sorted(logliks), "log likelihood never falls as k rises"


def kmeans_is_a_special_case():
    """K-Means is EM with spherical equal-variance Gaussians and hard assignment."""
    rng = np.random.default_rng(3)
    X = np.vstack([rng.normal([0, 0], 0.6, (150, 2)),
                   rng.normal([4, 4], 0.6, (150, 2))])

    km = KMeans(n_clusters=2, n_init=10, random_state=0).fit(X)
    em = GaussianMixture(n_components=2, covariance_type="spherical",
                         random_state=0, n_init=5).fit(X)

    a = np.sort(km.cluster_centers_, axis=0)
    b = np.sort(em.means_, axis=0)
    assert np.allclose(a, b, atol=0.15), f"\n{a}\n{b}"

    print(f"\n  on well-separated spherical data the two agree to within 0.15:")
    print(f"       K-Means centres {np.round(a, 3).tolist()}")
    print(f"       EM       means  {np.round(b, 3).tolist()}")


def main():
    print("Experiment 10 -- EM clustering")
    # Step 1: Compare soft and hard assignment
    soft_versus_hard()
    # Step 2: Fit elliptical clusters with EM
    em_finds_elliptical_clusters()
    # Step 3: Choose k by BIC
    choose_k_by_bic()
    # Step 4: See K-Means as a special case of EM
    kmeans_is_a_special_case()
    print("\n  EM behaviour verified")


if __name__ == "__main__":
    main()

5. Execution and Results

In WEKA, from the command line, 10_em_clustering_weka.sh:

OUTPUT


$ java weka.clusterers.EM -N -1 -I 100 -t data/iris.arff -c last


=== Clustering stats for training data ===


EM
==

Number of clusters selected by cross validation: 5
Number of iterations performed: 16


              Cluster
Attribute           0       1       2       3       4
               (0.18)  (0.23)  (0.28)  (0.15)  (0.15)
======================================================
sepallength
  mean          4.7748  6.8585  6.1613  5.2823  5.5432
  std. dev.     0.2405  0.5228  0.4138  0.2407  0.3159

sepalwidth
  mean          3.1789  3.0862  2.8547  3.7037  2.5786
  std. dev.     0.2599  0.2891  0.2687  0.2857  0.2512

petallength
  mean          1.4194  5.7859  4.7484  1.5173   3.863
  std. dev.     0.1692  0.4745  0.3193  0.1592  0.3516

petalwidth
  mean          0.1948  2.1327  1.5757  0.3028  1.1696
  std. dev.     0.0557  0.2359  0.2196  0.1212  0.1351

Clustered Instances

0       28 ( 19%)
1       35 ( 23%)
2       42 ( 28%)
3       22 ( 15%)
4       23 ( 15%)


Log likelihood: -1.60803


Class attribute: class
Classes to Clusters:

  0  1  2  3  4  <-- assigned to cluster
 28  0  0 22  0 | Iris-setosa
  0  0 27  0 23 | Iris-versicolor
  0 35 15  0  0 | Iris-virginica

Cluster 0 <-- Iris-setosa
Cluster 1 <-- Iris-virginica
Cluster 2 <-- Iris-versicolor
Cluster 3 <-- No class
Cluster 4 <-- No class

Incorrectly clustered instances :   60.0     40      %


$ java weka.clusterers.EM -N 3 -I 100 -t data/iris.arff -c last


=== Clustering stats for training data ===


EM
==

Number of clusters: 3
Number of iterations performed: 10


              Cluster
Attribute           0       1       2
               (0.41)  (0.33)  (0.25)
======================================
sepallength
  mean          5.9275   5.006  6.8085
  std. dev.     0.4817  0.3489  0.5339

sepalwidth
  mean          2.7503   3.418  3.0709
  std. dev.     0.2956  0.3772  0.2867

petallength
  mean          4.4057   1.464  5.7233
  std. dev.     0.5254  0.1718  0.4991

petalwidth
  mean          1.4131   0.244  2.1055
  std. dev.     0.2627  0.1061  0.2456

Clustered Instances

0       64 ( 43%)
1       50 ( 33%)
2       36 ( 24%)


Log likelihood: -2.055


Class attribute: class
Classes to Clusters:

  0  1  2  <-- assigned to cluster
  0 50  0 | Iris-setosa
 50  0  0 | Iris-versicolor
 14  0 36 | Iris-virginica

Cluster 0 <-- Iris-versicolor
Cluster 1 <-- Iris-setosa
Cluster 2 <-- Iris-virginica

Incorrectly clustered instances :   14.0      9.3333 %

In Python, 10_em_clustering.py:

OUTPUT

Experiment 10 -- EM clustering
  soft assignment: 40 of 400 points have max probability below 0.8
       least certain point x=1.487, probabilities [0.487, 0.513]
       K-Means would give that point a confident 0 or 1 and tell
       you nothing about the doubt

  elliptical clusters: K-Means 0.5050 (near chance --
       it splits along x and cuts BOTH ellipses)
       EM with its DEFAULT k-means init: 0.5233 on every seed -- it inherits the same blind spot
       EM across 8 seeds: log likelihood -4.3459 to -3.6331
       best-likelihood run  -3.6331 -> accuracy 1.0000
       worst-likelihood run -4.3459 -> accuracy 0.5000
       selecting by the OBJECTIVE finds the right clustering without
       ever looking at the labels -- so n_init matters for EM too

      k       BIC       AIC   loglik
      1    1050.67   1008.52  -3.2684
      2     794.71    707.40  -2.1647
      3     801.55    669.08  -1.9369
      4     849.25    671.62  -1.8454
      5     901.39    678.60  -1.7687
      6     923.46    655.51  -1.5917
       BIC is LOWEST at k=2
       (lower BIC is better -- the opposite of a log likelihood,
        which always improves with more components)

  on well-separated spherical data the two agree to within 0.15:
       K-Means centres [[0.027, 0.032], [3.979, 4.12]]
       EM       means  [[0.027, 0.033], [3.979, 4.12]]

  EM behaviour verified

Left to choose, EM picks 5 clusters on iris by cross-validation — more than the three species, and 40% "incorrectly clustered" against them, because the likelihood rewards splitting a species. Told 3, it does better than K-Means: 14 errors (9.3%) against 17.

The Python half fits a GaussianMixture, prints the responsibilities for a few boundary points to make "soft assignment" concrete, and selects k by BIC.

RESULT

EM chose 5 clusters by itself; with 3 it misplaced 14 flowers, three fewer than K-Means.

Experiment 11 — Decision tree with J48

1. Question

Build a decision tree classifier with J48, and evaluate it.

2. Aim

Build and read a J48 tree, and evaluate it by cross-validation.

3. Steps

In WEKA, from the command line, 11_decision_tree_weka.sh:

  1. Build J48 on the weather data, and cross-validate it 10 ways (Classify tab).
  2. The same tree, unpruned (unpruned = True).

In Python, 11_decision_tree.py:

  1. Trace ID3 on the weather data.
  2. Compute C4.5's gain ratio.
  3. Check that scikit-learn picks the same root.
  4. Watch the tree overfit as it deepens.

IN THE EXPLORER

J48 is WEKA's implementation of C4.5. State that; it is a two-mark question.

  1. Load weather.nominal.arff.
  2. Classify → Choose → trees/J48 - confidenceFactor = 0.25 — lower means more pruning - minNumObj = 2 — minimum instances per leaf - unpruned = False - binarySplits = False
  3. Test options → Cross-validation, Folds 10
  4. Start. Then right-click the result → Visualize tree.

Read from the output: the tree itself, Number of Leaves, Size of the tree, Correctly Classified Instances, the confusion matrix, and per-class precision, recall, F-measure and ROC area.

(n/m) at a leaf means n instances reached it and m were misclassified.

4. Programme

In WEKA, from the command line, 11_decision_tree_weka.sh:

#!/usr/bin/env bash
# Experiment 11 in WEKA 3.8.7, from the command line: a decision tree with J48.
# Each command runs the WEKA class that the Explorer runs for the same choice, and prints
# what the Explorer shows in its output pane. The click-path is on the lab page.
# Needs tools/data-science/setup_weka.sh; run as  bash 11_decision_tree_weka.sh
set -euo pipefail
source "$(dirname "$0")/weka.sh"

# Step 1: Build J48 on the weather data, and cross-validate it 10 ways (Classify tab)
weka weka.classifiers.trees.J48 -C 0.25 -M 2 -t $DATA/weather.nominal.arff

# Step 2: The same tree, unpruned (unpruned = True)
weka weka.classifiers.trees.J48 -U -M 2 -t $DATA/weather.nominal.arff -o

In Python, 11_decision_tree.py:

"""Experiment 11 — Decision tree (WEKA's J48 is C4.5).

Reproduces Unit 4 section 4.5's hand-traced ID3 calculation EXACTLY, then shows
overfitting on iris.

The entropies and gains asserted here are the numbers written out in the notes.
If either changes, this fails.
"""
import math
import numpy as np
import pandas as pd
from sklearn.tree import DecisionTreeClassifier, export_text
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split, cross_val_score
from weather import weather_frame


def entropy(*counts):
    """Shannon entropy in BITS. Note 0 log 0 is taken as 0."""
    n = sum(counts)
    return -sum((c / n) * math.log2(c / n) for c in counts if c > 0)


def info_gain(df, attribute, target="Play"):
    """Gain(D, A) = Entropy(D) - sum_v (|Dv|/|D|) Entropy(Dv)."""
    total = len(df)
    before = entropy(*df[target].value_counts().tolist())
    after = sum(
        len(sub) / total * entropy(*sub[target].value_counts().tolist())
        for _, sub in df.groupby(attribute)
    )
    return before - after


def split_info(df, attribute):
    """Entropy of the PARTITION SIZES -- C4.5's normaliser."""
    total = len(df)
    return entropy(*[len(sub) for _, sub in df.groupby(attribute)])


def unit4_id3_trace():
    df = weather_frame()
    assert (df.Play == "Yes").sum() == 9 and (df.Play == "No").sum() == 5

    root_entropy = entropy(9, 5)
    assert round(root_entropy, 4) == 0.9403, root_entropy

    gains = {a: round(info_gain(df, a), 4) for a in df.columns[:-1]}
    assert gains == {"Outlook": 0.2467, "Temperature": 0.0292,
                     "Humidity": 0.1518, "Wind": 0.0481}, gains
    assert max(gains, key=gains.get) == "Outlook", "Outlook must be the root"

    # Overcast is already pure -- a leaf with no further splitting.
    overcast = df[df.Outlook == "Overcast"]
    assert set(overcast.Play) == {"Yes"} and len(overcast) == 4

    # Sunny branch: Humidity splits it perfectly (gain == the branch entropy).
    sunny = df[df.Outlook == "Sunny"]
    assert round(entropy(2, 3), 4) == 0.9710
    sg = {a: round(info_gain(sunny, a), 4) for a in ["Temperature", "Humidity", "Wind"]}
    assert sg == {"Temperature": 0.5710, "Humidity": 0.9710, "Wind": 0.0200}, sg

    # Rain branch: Wind splits it perfectly.
    rain = df[df.Outlook == "Rain"]
    rg = {a: round(info_gain(rain, a), 4) for a in ["Temperature", "Humidity", "Wind"]}
    assert rg == {"Temperature": 0.0200, "Humidity": 0.0200, "Wind": 0.9710}, rg

    print(f"  ID3: root entropy {root_entropy:.4f}; gains {gains}")
    print(f"       root = Outlook (0.2467); Sunny -> Humidity; Rain -> Wind")


def unit4_gain_ratio():
    """Section 4.6: C4.5's gain ratio, and the many-valued-attribute bias."""
    df = weather_frame()

    ratios = {}
    for a in df.columns[:-1]:
        ratios[a] = round(info_gain(df, a) / split_info(df, a), 4)
    assert ratios == {"Outlook": 0.1564, "Temperature": 0.0188,
                      "Humidity": 0.1518, "Wind": 0.0488}, ratios
    assert max(ratios, key=ratios.get) == "Outlook"

    # Add a unique identifier: 14 pure singleton branches, the MAXIMUM gain.
    df2 = df.copy()
    df2["Day"] = range(1, 15)
    day_gain = info_gain(df2, "Day")
    assert round(day_gain, 4) == 0.9403, "an identifier achieves the maximum gain"
    assert round(split_info(df2, "Day"), 4) == round(math.log2(14), 4) == 3.8074

    day_ratio = day_gain / split_info(df2, "Day")
    assert round(day_ratio, 4) == 0.2470

    # The honest point from the notes: gain ratio REDUCES but does not
    # ELIMINATE the bias -- Day still outranks Outlook.
    assert day_ratio > ratios["Outlook"], \
        "the notes state gain ratio does not fully fix the bias"

    print(f"  Gain ratio: Outlook {ratios['Outlook']}, but a Day identifier "
          f"still scores {day_ratio:.4f} -- reduced, not eliminated")


def sklearn_tree_agrees():
    """scikit-learn with criterion='entropy' must pick the same root."""
    df = weather_frame()
    X = pd.get_dummies(df.drop(columns="Play"))
    y = (df.Play == "Yes").astype(int)

    clf = DecisionTreeClassifier(criterion="entropy", random_state=0).fit(X, y)
    text = export_text(clf, feature_names=list(X.columns))
    root = text.splitlines()[0]
    assert "Outlook_Overcast" in root or "Outlook" in root, \
        f"the first split should involve Outlook, got: {root}"

    assert clf.score(X, y) == 1.0, "an unpruned tree memorises the training set"
    print(f"  sklearn tree: first split {root.strip()}; training accuracy 1.00")
    print("       (perfect training accuracy is the SIGNAL of overfitting)")


def overfitting_curve():
    """Section 4.8: training accuracy rises with depth while test accuracy does not."""
    X, y = load_iris(return_X_y=True)
    Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.4, random_state=42,
                                          stratify=y)
    rows = []
    for depth in range(1, 11):
        t = DecisionTreeClassifier(max_depth=depth, random_state=0).fit(Xtr, ytr)
        rows.append((depth, t.score(Xtr, ytr), t.score(Xte, yte)))

    train = [r[1] for r in rows]
    assert train == sorted(train), "training accuracy must be non-decreasing with depth"
    assert train[-1] == 1.0, "a deep enough tree memorises the training set"

    best = max(rows, key=lambda r: r[2])
    gap = train[-1] - rows[-1][2]
    assert gap > 0, "the train/test gap IS the overfitting"

    print("  depth  train   test")
    for d, tr, te in rows:
        print(f"    {d:2d}   {tr:.3f}  {te:.3f}" + ("   <- best test" if (d, tr, te) == best else ""))
    print(f"       final train/test gap {gap:.3f} -- deeper is not better")


def main():
    print("Experiment 11 -- Decision trees")
    # Step 1: Trace ID3 on the weather data
    unit4_id3_trace()
    # Step 2: Compute C4.5's gain ratio
    unit4_gain_ratio()
    # Step 3: Check that scikit-learn picks the same root
    sklearn_tree_agrees()
    # Step 4: Watch the tree overfit as it deepens
    overfitting_curve()
    print("  all Unit 4 tree calculations reproduced")


if __name__ == "__main__":
    main()

5. Execution and Results

In WEKA, from the command line, 11_decision_tree_weka.sh:

OUTPUT


$ java weka.classifiers.trees.J48 -C 0.25 -M 2 -t data/weather.nominal.arff

Options: -C 0.25 -M 2

=== Classifier model (full training set) ===

J48 pruned tree
------------------

outlook = sunny
|   humidity = high: no (3.0)
|   humidity = normal: yes (2.0)
outlook = overcast: yes (4.0)
outlook = rainy
|   windy = TRUE: no (2.0)
|   windy = FALSE: yes (3.0)

Number of Leaves  :     5

Size of the tree :  8


Time taken to build model: 0.09 seconds

Time taken to test model on training data: 0 seconds

=== Error on training data ===

Correctly Classified Instances          14              100      %
Incorrectly Classified Instances         0                0      %
Kappa statistic                          1
Mean absolute error                      0
Root mean squared error                  0
Relative absolute error                  0      %
Root relative squared error              0      %
Total Number of Instances               14


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 1.000    0.000    1.000      1.000    1.000      1.000    1.000     1.000     yes
                 1.000    0.000    1.000      1.000    1.000      1.000    1.000     1.000     no
Weighted Avg.    1.000    0.000    1.000      1.000    1.000      1.000    1.000     1.000


=== Confusion Matrix ===

 a b   <-- classified as
 9 0 | a = yes
 0 5 | b = no

Time taken to perform cross-validation: 0.01 seconds


=== Stratified cross-validation ===

Correctly Classified Instances           7               50      %
Incorrectly Classified Instances         7               50      %
Kappa statistic                         -0.0426
Mean absolute error                      0.4167
Root mean squared error                  0.5984
Relative absolute error                 87.5    %
Root relative squared error            121.2987 %
Total Number of Instances               14


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 0.556    0.600    0.625      0.556    0.588      -0.043   0.633     0.758     yes
                 0.400    0.444    0.333      0.400    0.364      -0.043   0.633     0.457     no
Weighted Avg.    0.500    0.544    0.521      0.500    0.508      -0.043   0.633     0.650


=== Confusion Matrix ===

 a b   <-- classified as
 5 4 | a = yes
 3 2 | b = no


$ java weka.classifiers.trees.J48 -U -M 2 -t data/weather.nominal.arff -o

Time taken to test model on training data: 0 seconds

=== Error on training data ===

Correctly Classified Instances          14              100      %
Incorrectly Classified Instances         0                0      %
Kappa statistic                          1
Mean absolute error                      0
Root mean squared error                  0
Relative absolute error                  0      %
Root relative squared error              0      %
Total Number of Instances               14


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 1.000    0.000    1.000      1.000    1.000      1.000    1.000     1.000     yes
                 1.000    0.000    1.000      1.000    1.000      1.000    1.000     1.000     no
Weighted Avg.    1.000    0.000    1.000      1.000    1.000      1.000    1.000     1.000


=== Confusion Matrix ===

 a b   <-- classified as
 9 0 | a = yes
 0 5 | b = no

Time taken to perform cross-validation: 0.01 seconds


=== Stratified cross-validation ===

Correctly Classified Instances           8               57.1429 %
Incorrectly Classified Instances         6               42.8571 %
Kappa statistic                          0.0667
Mean absolute error                      0.369
Root mean squared error                  0.5713
Relative absolute error                 77.5    %
Root relative squared error            115.7978 %
Total Number of Instances               14


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 0.667    0.600    0.667      0.667    0.667      0.067    0.689     0.792     yes
                 0.400    0.333    0.400      0.400    0.400      0.067    0.689     0.486     no
Weighted Avg.    0.571    0.505    0.571      0.571    0.571      0.067    0.689     0.683


=== Confusion Matrix ===

 a b   <-- classified as
 6 3 | a = yes
 3 2 | b = no

In Python, 11_decision_tree.py:

OUTPUT

Experiment 11 -- Decision trees
  ID3: root entropy 0.9403; gains {'Outlook': 0.2467, 'Temperature': 0.0292, 'Humidity': 0.1518, 'Wind': 0.0481}
       root = Outlook (0.2467); Sunny -> Humidity; Rain -> Wind
  Gain ratio: Outlook 0.1564, but a Day identifier still scores 0.2470 -- reduced, not eliminated
  sklearn tree: first split |--- Outlook_Overcast <= 0.50; training accuracy 1.00
       (perfect training accuracy is the SIGNAL of overfitting)
  depth  train   test
     1   0.667  0.667
     2   0.967  0.917
     3   0.978  0.983   <- best test
     4   0.989  0.983
     5   1.000  0.983
     6   1.000  0.983
     7   1.000  0.983
     8   1.000  0.983
     9   1.000  0.983
    10   1.000  0.983
       final train/test gap 0.017 -- deeper is not better
  all Unit 4 tree calculations reproduced

The root is outlook, as Unit 4 §4.5's information gains say it must be: 5 leaves, every training day classified correctly. Cross-validated, it gets 7 of 14 right — 50%, worse than always saying "yes" (64%). Fourteen days are too few to learn from and test on.

The Python half builds a tree with criterion='entropy' on the weather data and asserts that the root split is Outlook with information gain 0.2467 — matching Unit 4 §4.5's hand calculation exactly. It also demonstrates overfitting by plotting train versus test accuracy against max_depth.

RESULT

J48's root is outlook, as the hand calculation says; on 14 instances its cross-validated accuracy is 50%.

Experiment 12 — Naïve Bayes, compared with the tree

1. Question

Classify with Naive Bayes, and compare it with the decision tree.

2. Aim

Build a Naive Bayes classifier in WEKA, and compare it with J48 fairly.

3. Steps

In WEKA, from the command line, 12_naive_bayes_weka.sh:

  1. Naive Bayes on the weather data, cross-validated 10 ways.
  2. J48 and Naive Bayes on the vote data, the same 10 folds (seed 1), for a fair comparison.

In Python, 12_naive_bayes.py:

  1. Classify by hand, as the notes do.
  2. Classify Practice Problem 3's day.
  3. See one unseen value zero the product, and smooth it.
  4. Work in logs, to avoid underflow.
  5. Check that scikit-learn agrees.
  6. Compare with the tree by a paired t-test.

IN THE EXPLORER

  1. Classify → Choose → bayes/NaiveBayes - useSupervisedDiscretization = True often helps on numeric data
  2. Same 10-fold cross-validation. Start.
  3. Compare with Experiment 11's numbers.
  4. Use the Experimenter for a proper comparison: Experimenter → New → add both classifiers → add the dataset → Run → Analyse → Paired T-Tester.

That last step is what separates a good answer from a complete one. Comparing two accuracy figures from a single run proves nothing; a paired t-test over the cross-validation folds is the correct method, and it is exactly Statistical Foundations for Data Science Unit 5's paired t-test applied here.

4. Programme

In WEKA, from the command line, 12_naive_bayes_weka.sh:

#!/usr/bin/env bash
# Experiment 12 in WEKA 3.8.7, from the command line: Naive Bayes, compared with J48.
# Each command runs the WEKA class that the Explorer runs for the same choice, and prints
# what the Explorer shows in its output pane. The click-path is on the lab page.
# Needs tools/data-science/setup_weka.sh; run as  bash 12_naive_bayes_weka.sh
set -euo pipefail
source "$(dirname "$0")/weka.sh"

# Step 1: Naive Bayes on the weather data, cross-validated 10 ways
weka weka.classifiers.bayes.NaiveBayes -t $DATA/weather.nominal.arff

# Step 2: J48 and Naive Bayes on the vote data, the same 10 folds (seed 1), for a fair comparison
weka weka.classifiers.trees.J48 -t $DATA/vote.arff -x 10 -s 1 -o
weka weka.classifiers.bayes.NaiveBayes -t $DATA/vote.arff -x 10 -s 1 -o

In Python, 12_naive_bayes.py:

"""Experiment 12 — Naive Bayes, and comparison with the decision tree.

Reproduces Unit 4 section 4.12's hand calculation exactly, demonstrates the
zero-frequency problem, fixes it with Laplace smoothing, and runs the PAIRED
t-test that WEKA's Experimenter does.
"""
import numpy as np
import pandas as pd
from scipy import stats
from sklearn.naive_bayes import CategoricalNB, GaussianNB
from sklearn.tree import DecisionTreeClassifier
from sklearn.datasets import load_iris
from sklearn.model_selection import StratifiedKFold, cross_val_score
from weather import weather_frame


def posteriors(df, query, alpha=0.0, target="Play"):
    """Unnormalised P(X|C)P(C) for each class, with optional Laplace smoothing.

    alpha = 0 is the raw count estimate; alpha = 1 is add-one smoothing, where
    the denominator gains k, the number of DISTINCT VALUES of that attribute --
    which is the step students most often omit.
    """
    n = len(df)
    out = {}
    for cls, sub in df.groupby(target):
        p = len(sub) / n                       # the prior
        for attribute, value in query.items():
            k = df[attribute].nunique()
            p *= (int((sub[attribute] == value).sum()) + alpha) / (len(sub) + alpha * k)
        out[cls] = p
    return out


def normalise(d):
    total = sum(d.values())
    return {k: v / total for k, v in d.items()}


def unit4_hand_calculation():
    df = weather_frame()
    X = {"Outlook": "Sunny", "Temperature": "Cool",
         "Humidity": "High", "Wind": "Strong"}

    post = posteriors(df, X)
    assert round(post["Yes"], 6) == 0.005291, post
    assert round(post["No"], 6) == 0.020571, post

    # The exact fractions the notes show
    assert abs(post["Yes"] - 486 / 91854) < 1e-12
    assert abs(post["No"] - 180 / 8750) < 1e-12

    norm = normalise(post)
    assert round(norm["No"], 4) == 0.7954
    assert round(norm["Yes"], 4) == 0.2046
    assert max(post, key=post.get) == "No"

    print(f"  4.12: P(X|Yes)P(Yes) = {post['Yes']:.6f}, "
          f"P(X|No)P(No) = {post['No']:.6f} -> predict No ({norm['No']:.1%})")


def practice_3():
    """Practice Problem 3: X = (Rain, Mild, Normal, Weak) -> Yes."""
    df = weather_frame()
    X = {"Outlook": "Rain", "Temperature": "Mild",
         "Humidity": "Normal", "Wind": "Weak"}

    raw = normalise(posteriors(df, X))
    assert round(raw["Yes"], 4) == 0.9025 and round(raw["No"], 4) == 0.0975

    smoothed = normalise(posteriors(df, X, alpha=1.0))
    # 0.8546 from exact fractions. The notes show 0.8547 because they multiply
    # the 4-decimal rounded factors, which is what you do by hand -- the two
    # differ in the fourth decimal and agree on everything that matters.
    assert round(smoothed["Yes"], 4) == 0.8546, smoothed
    assert max(smoothed, key=smoothed.get) == "Yes", "the argmax must not change"

    print(f"  Practice 3: P(Yes|X) {raw['Yes']:.4f} raw -> {smoothed['Yes']:.4f} "
          f"smoothed; argmax unchanged")


def zero_frequency():
    """The whole product collapses to zero from ONE unseen value."""
    df = weather_frame()
    X = {"Outlook": "Overcast", "Temperature": "Hot",
         "Humidity": "High", "Wind": "Strong"}

    # No Overcast day has Play = No.
    assert ((df.Outlook == "Overcast") & (df.Play == "No")).sum() == 0

    raw = posteriors(df, X)
    assert raw["No"] == 0.0, "one zero count vetoes the entire class"

    smoothed = posteriors(df, X, alpha=1.0)
    assert smoothed["No"] > 0.0, "Laplace smoothing removes the veto"

    # The smoothed Outlook|No distribution from the notes
    no = df[df.Play == "No"]
    k = df.Outlook.nunique()
    dist = {v: (int((no.Outlook == v).sum()) + 1) / (len(no) + k)
            for v in ["Overcast", "Sunny", "Rain"]}
    assert dist == {"Overcast": 0.125, "Sunny": 0.5, "Rain": 0.375}, dist
    assert abs(sum(dist.values()) - 1.0) < 1e-12, "still a valid distribution"

    print(f"  zero-frequency: P(X|No)P(No) = {raw['No']} raw -> "
          f"{smoothed['No']:.8f} smoothed")
    print(f"       P(Outlook|No) smoothed = {dist}, sums to 1.0")


def log_space_is_necessary():
    """Section 4.12: a product of many small probabilities UNDERFLOWS."""
    probs = np.full(400, 0.1)
    assert np.prod(probs) == 0.0, "400 factors of 0.1 underflow to exactly zero"
    logsum = np.sum(np.log(probs))
    assert np.isfinite(logsum) and logsum < 0
    print(f"  underflow: prod = {np.prod(probs)} but sum of logs = {logsum:.1f} "
          f"-- which is why implementations use logs")


def paired_t_test():
    """What WEKA's Experimenter -> Paired T-Tester does.

    Comparing two accuracy figures from a single split proves nothing. The
    correct method is a paired t-test over the cross-validation folds -- the
    same test as Course 4, Unit 5.
    """
    X, y = load_iris(return_X_y=True)
    cv = StratifiedKFold(n_splits=10, shuffle=True, random_state=42)

    tree = cross_val_score(DecisionTreeClassifier(random_state=0), X, y, cv=cv)
    bayes = cross_val_score(GaussianNB(), X, y, cv=cv)

    t, p = stats.ttest_rel(tree, bayes)

    assert len(tree) == len(bayes) == 10
    assert all(0 <= a <= 1 for a in tree) and all(0 <= a <= 1 for a in bayes)

    verdict = "significantly different" if p < 0.05 else "NOT significantly different"
    print(f"  paired t-test over 10 folds:")
    print(f"       tree  {tree.mean():.4f} +/- {tree.std():.4f}")
    print(f"       bayes {bayes.mean():.4f} +/- {bayes.std():.4f}")
    print(f"       t = {t:.4f}, p = {p:.4f} -> {verdict} at alpha = 0.05")


def sklearn_agrees():
    """CategoricalNB with alpha=1 must reach the same conclusion as the notes."""
    df = weather_frame()
    codes = {c: {v: i for i, v in enumerate(sorted(df[c].unique()))}
             for c in df.columns[:-1]}
    X = np.array([[codes[c][row[c]] for c in df.columns[:-1]]
                  for _, row in df.iterrows()])
    y = (df.Play == "Yes").astype(int).to_numpy()

    clf = CategoricalNB(alpha=1.0).fit(X, y)
    q = np.array([[codes["Outlook"]["Sunny"], codes["Temperature"]["Cool"],
                   codes["Humidity"]["High"], codes["Wind"]["Strong"]]])
    pred = clf.predict(q)[0]
    prob = clf.predict_proba(q)[0]

    assert pred == 0, "smoothed sklearn must also predict No"
    print(f"  sklearn CategoricalNB(alpha=1): predicts "
          f"{'Yes' if pred else 'No'} with P(No) = {prob[0]:.4f}")


def main():
    print("Experiment 12 -- Naive Bayes")
    # Step 1: Classify by hand, as the notes do
    unit4_hand_calculation()
    # Step 2: Classify Practice Problem 3's day
    practice_3()
    # Step 3: See one unseen value zero the product, and smooth it
    zero_frequency()
    # Step 4: Work in logs, to avoid underflow
    log_space_is_necessary()
    # Step 5: Check that scikit-learn agrees
    sklearn_agrees()
    # Step 6: Compare with the tree by a paired t-test
    paired_t_test()
    print("  all Unit 4 Bayes calculations reproduced")


if __name__ == "__main__":
    main()

5. Execution and Results

In WEKA, from the command line, 12_naive_bayes_weka.sh:

OUTPUT


$ java weka.classifiers.bayes.NaiveBayes -t data/weather.nominal.arff

=== Classifier model (full training set) ===

Naive Bayes Classifier

                Class
Attribute         yes     no
               (0.63) (0.38)
=============================
outlook
  sunny            3.0    4.0
  overcast         5.0    1.0
  rainy            4.0    3.0
  [total]         12.0    8.0

temperature
  hot              3.0    3.0
  mild             5.0    3.0
  cool             4.0    2.0
  [total]         12.0    8.0

humidity
  high             4.0    5.0
  normal           7.0    2.0
  [total]         11.0    7.0

windy
  TRUE             4.0    4.0
  FALSE            7.0    3.0
  [total]         11.0    7.0



Time taken to build model: 0.05 seconds

Time taken to test model on training data: 0 seconds

=== Error on training data ===

Correctly Classified Instances          13               92.8571 %
Incorrectly Classified Instances         1                7.1429 %
Kappa statistic                          0.8372
Mean absolute error                      0.2917
Root mean squared error                  0.3392
Relative absolute error                 62.8233 %
Root relative squared error             70.7422 %
Total Number of Instances               14


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 1.000    0.200    0.900      1.000    0.947      0.849    0.922     0.947     yes
                 0.800    0.000    1.000      0.800    0.889      0.849    0.911     0.911     no
Weighted Avg.    0.929    0.129    0.936      0.929    0.926      0.849    0.918     0.934


=== Confusion Matrix ===

 a b   <-- classified as
 9 0 | a = yes
 1 4 | b = no

Time taken to perform cross-validation: 0.01 seconds


=== Stratified cross-validation ===

Correctly Classified Instances           8               57.1429 %
Incorrectly Classified Instances         6               42.8571 %
Kappa statistic                         -0.0244
Mean absolute error                      0.4374
Root mean squared error                  0.4916
Relative absolute error                 91.8631 %
Root relative squared error             99.6492 %
Total Number of Instances               14


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 0.778    0.800    0.636      0.778    0.700      -0.026   0.578     0.697     yes
                 0.200    0.222    0.333      0.200    0.250      -0.026   0.578     0.557     no
Weighted Avg.    0.571    0.594    0.528      0.571    0.539      -0.026   0.578     0.647


=== Confusion Matrix ===

 a b   <-- classified as
 7 2 | a = yes
 4 1 | b = no


$ java weka.classifiers.trees.J48 -t data/vote.arff -x 10 -s 1 -o

Time taken to test model on training data: 0.01 seconds

=== Error on training data ===

Correctly Classified Instances         423               97.2414 %
Incorrectly Classified Instances        12                2.7586 %
Kappa statistic                          0.9418
Mean absolute error                      0.0519
Root mean squared error                  0.1506
Relative absolute error                 10.9481 %
Root relative squared error             30.9353 %
Total Number of Instances              435


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 0.978    0.036    0.978      0.978    0.978      0.942    0.986     0.987     democrat
                 0.964    0.022    0.964      0.964    0.964      0.942    0.986     0.970     republican
Weighted Avg.    0.972    0.031    0.972      0.972    0.972      0.942    0.986     0.981


=== Confusion Matrix ===

   a   b   <-- classified as
 261   6 |   a = democrat
   6 162 |   b = republican

Time taken to perform cross-validation: 0.04 seconds


=== Stratified cross-validation ===

Correctly Classified Instances         419               96.3218 %
Incorrectly Classified Instances        16                3.6782 %
Kappa statistic                          0.9224
Mean absolute error                      0.0611
Root mean squared error                  0.1748
Relative absolute error                 12.887  %
Root relative squared error             35.9085 %
Total Number of Instances              435


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 0.970    0.048    0.970      0.970    0.970      0.922    0.971     0.965     democrat
                 0.952    0.030    0.952      0.952    0.952      0.922    0.971     0.947     republican
Weighted Avg.    0.963    0.041    0.963      0.963    0.963      0.922    0.971     0.958


=== Confusion Matrix ===

   a   b   <-- classified as
 259   8 |   a = democrat
   8 160 |   b = republican


$ java weka.classifiers.bayes.NaiveBayes -t data/vote.arff -x 10 -s 1 -o

Time taken to test model on training data: 0.01 seconds

=== Error on training data ===

Correctly Classified Instances         393               90.3448 %
Incorrectly Classified Instances        42                9.6552 %
Kappa statistic                          0.7999
Mean absolute error                      0.0975
Root mean squared error                  0.2944
Relative absolute error                 20.555  %
Root relative squared error             60.469  %
Total Number of Instances              435


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 0.891    0.077    0.948      0.891    0.919      0.802    0.974     0.984     democrat
                 0.923    0.109    0.842      0.923    0.881      0.802    0.974     0.960     republican
Weighted Avg.    0.903    0.089    0.907      0.903    0.904      0.802    0.974     0.975


=== Confusion Matrix ===

   a   b   <-- classified as
 238  29 |   a = democrat
  13 155 |   b = republican

Time taken to perform cross-validation: 0.01 seconds


=== Stratified cross-validation ===

Correctly Classified Instances         392               90.1149 %
Incorrectly Classified Instances        43                9.8851 %
Kappa statistic                          0.7949
Mean absolute error                      0.0995
Root mean squared error                  0.2977
Relative absolute error                 20.9815 %
Root relative squared error             61.1406 %
Total Number of Instances              435


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 0.891    0.083    0.944      0.891    0.917      0.797    0.973     0.984     democrat
                 0.917    0.109    0.842      0.917    0.877      0.797    0.973     0.957     republican
Weighted Avg.    0.901    0.093    0.905      0.901    0.902      0.797    0.973     0.973


=== Confusion Matrix ===

   a   b   <-- classified as
 238  29 |   a = democrat
  14 154 |   b = republican

In Python, 12_naive_bayes.py:

OUTPUT

Experiment 12 -- Naive Bayes
  4.12: P(X|Yes)P(Yes) = 0.005291, P(X|No)P(No) = 0.020571 -> predict No (79.5%)
  Practice 3: P(Yes|X) 0.9025 raw -> 0.8546 smoothed; argmax unchanged
  zero-frequency: P(X|No)P(No) = 0.0 raw -> 0.00683309 smoothed
       P(Outlook|No) smoothed = {'Overcast': 0.125, 'Sunny': 0.5, 'Rain': 0.375}, sums to 1.0
  underflow: prod = 0.0 but sum of logs = -921.0 -- which is why implementations use logs
  sklearn CategoricalNB(alpha=1): predicts No with P(No) = 0.7201
  paired t-test over 10 folds:
       tree  0.9400 +/- 0.0554
       bayes 0.9533 +/- 0.0521
       t = -0.6882, p = 0.5086 -> NOT significantly different at alpha = 0.05
  all Unit 4 Bayes calculations reproduced

On the weather data Naive Bayes gets 8 of 14 by cross-validation (57%), one more than J48. On the 435-vote data, with the same 10 folds for both, J48 gets 96.3% and Naive Bayes 90.1% — a clear difference, and the Experimenter's paired t-test is how to say whether it is significant. The Experimenter is a GUI; the Python half runs the same paired t-test.

The Python half reproduces Unit 4 §4.12's hand calculation — for X = (Sunny, Cool, High, Strong) the unnormalised posteriors are 0.005291 for Yes and 0.020571 for No, giving P(No|X) = 0.7954 — then demonstrates the zero-frequency problem and fixes it with Laplace smoothing, and finally runs a paired t-test between the tree and Naïve Bayes across 10 folds.

RESULT

On the vote data J48 beats Naive Bayes, 96.3% to 90.1%, on the same folds.

Experiment 13 — Rule-based classification

1. Question

Classify with rules, and compare them with a baseline.

2. Aim

Learn rules with OneR, JRip and PART, and measure them against ZeroR.

3. Steps

In WEKA, from the command line, 13_rules_weka.sh:

  1. ZeroR first, the baseline (rules/ZeroR).
  2. OneR, the single best attribute (rules/OneR).
  3. RIPPER (rules/JRip).
  4. PART, rules from partial trees (rules/PART).

In Python, 13_rules.py:

  1. Write the rules, and measure their coverage and accuracy.
  2. Check that they classify every day correctly.
  3. Run ZeroR first, as the baseline.
  4. See the accuracy paradox on imbalanced data.
  5. Order rules that overlap.

IN THE EXPLORER

  1. Classify → Choose → rules/JRip (this is RIPPER) - folds = 3 — used for the pruning split - minNo = 2
  2. Or rules/PART, which builds partial C4.5 trees and takes the best leaf as a rule each round.

  3. Also try rules/ZeroR (always predicts the majority class) and rules/OneR (a single best attribute).

Always run ZeroR first. It is your baseline: if your sophisticated classifier does not beat "always guess the majority", it has learned nothing. On an imbalanced dataset ZeroR alone can score 95%, which is the accuracy paradox of Unit 4 §4.9 made concrete in one click.

4. Programme

In WEKA, from the command line, 13_rules_weka.sh:

#!/usr/bin/env bash
# Experiment 13 in WEKA 3.8.7, from the command line: rule-based classification.
# Each command runs the WEKA class that the Explorer runs for the same choice, and prints
# what the Explorer shows in its output pane. The click-path is on the lab page.
# Needs tools/data-science/setup_weka.sh; run as  bash 13_rules_weka.sh
set -euo pipefail
source "$(dirname "$0")/weka.sh"

# Step 1: ZeroR first, the baseline (rules/ZeroR)
weka weka.classifiers.rules.ZeroR -t $DATA/weather.nominal.arff -o

# Step 2: OneR, the single best attribute (rules/OneR)
weka weka.classifiers.rules.OneR -t $DATA/weather.nominal.arff

# Step 3: RIPPER (rules/JRip)
weka weka.classifiers.rules.JRip -F 3 -N 2.0 -t $DATA/weather.nominal.arff

# Step 4: PART, rules from partial trees (rules/PART)
weka weka.classifiers.rules.PART -t $DATA/weather.nominal.arff

In Python, 13_rules.py:

"""Experiment 13 — Rule-based classification.

WEKA: rules/JRip (RIPPER), rules/PART, rules/ZeroR, rules/OneR.

Extracts the five weather rules of Unit 4 section 4.10 from a decision tree,
computes each rule's coverage and accuracy, and shows why ZeroR must always be
run first.
"""
import numpy as np
import pandas as pd
from sklearn.dummy import DummyClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.datasets import load_iris, load_breast_cancer
from sklearn.model_selection import cross_val_score, StratifiedKFold
from weather import weather_frame

RULES = [
    ({"Outlook": "Overcast"},                      "Yes"),
    ({"Outlook": "Sunny", "Humidity": "Normal"},   "Yes"),
    ({"Outlook": "Sunny", "Humidity": "High"},     "No"),
    ({"Outlook": "Rain", "Wind": "Weak"},          "Yes"),
    ({"Outlook": "Rain", "Wind": "Strong"},        "No"),
]


def matches(row, antecedent):
    return all(row[k] == v for k, v in antecedent.items())


def rule_quality(df, antecedent, consequent, target="Play"):
    """Coverage = fraction of records the antecedent fires on.
       Accuracy = of those, the fraction with the right class."""
    covered = df[df.apply(lambda r: matches(r, antecedent), axis=1)]
    coverage = len(covered) / len(df)
    accuracy = (covered[target] == consequent).mean() if len(covered) else float("nan")
    return coverage, accuracy, len(covered)


def unit4_rules():
    df = weather_frame()
    print("  the five tree paths as rules (Unit 4 section 4.10)")

    total_covered = 0
    for ant, con in RULES:
        cov, acc, n = rule_quality(df, ant, con)
        conds = " AND ".join(f"{k}={v}" for k, v in ant.items())
        print(f"    IF {conds:38s} THEN {con:3s}  "
              f"coverage {cov:.4f} ({n:2d}/14)  accuracy {acc:.4f}")
        assert acc == 1.0, f"every tree-derived rule must be 100% accurate here"
        total_covered += n

    assert total_covered == 14, "the rules must be EXHAUSTIVE -- 14 records covered"

    # And MUTUALLY EXCLUSIVE: no record fires two rules.
    for _, row in df.iterrows():
        fired = [i for i, (a, _) in enumerate(RULES) if matches(row, a)]
        assert len(fired) == 1, f"record fired {len(fired)} rules, expected 1"

    print("       exhaustive (14/14 covered) and mutually exclusive (1 rule each)")
    print("       -- automatic, because tree paths PARTITION the space")


def rules_predict_correctly():
    df = weather_frame()

    def classify(row, default="Yes"):
        for ant, con in RULES:
            if matches(row, ant):
                return con
        return default                      # needed only if rules are not exhaustive

    predictions = df.apply(classify, axis=1)
    assert (predictions == df.Play).all(), "the rule set reproduces every label"
    print(f"  rule-set accuracy on the training data: "
          f"{(predictions == df.Play).mean():.4f}")


def zeror_baseline():
    """ALWAYS run ZeroR first. On imbalanced data it alone can look excellent."""
    print("\n  ZeroR baseline (WEKA: rules/ZeroR)")

    df = weather_frame()
    majority = df.Play.value_counts().idxmax()
    zeror = (df.Play == majority).mean()
    assert majority == "Yes" and round(zeror, 4) == round(9 / 14, 4)
    print(f"    weather: always predict '{majority}' -> {zeror:.4f}")

    for name, load in [("iris", load_iris), ("breast cancer", load_breast_cancer)]:
        X, y = load(return_X_y=True)
        cv = StratifiedKFold(n_splits=10, shuffle=True, random_state=0)
        base = cross_val_score(DummyClassifier(strategy="most_frequent"), X, y, cv=cv).mean()
        tree = cross_val_score(DecisionTreeClassifier(random_state=0), X, y, cv=cv).mean()
        print(f"    {name:14s} ZeroR {base:.4f}   tree {tree:.4f}   "
              f"(+{tree - base:.4f})")
        assert tree > base, f"a classifier must beat the baseline on {name}"


def accuracy_paradox_in_one_click():
    """A 95/5 imbalance: ZeroR alone scores 95% and finds nothing."""
    rng = np.random.default_rng(0)
    n = 2000
    y = np.r_[np.zeros(1900), np.ones(100)].astype(int)
    X = rng.normal(0, 1, (n, 4))
    X[y == 1] += 0.3                       # a WEAK signal

    cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
    base = cross_val_score(DummyClassifier(strategy="most_frequent"), X, y, cv=cv).mean()
    assert round(base, 4) == 0.95, base

    from sklearn.metrics import recall_score
    from sklearn.model_selection import cross_val_predict
    pred = cross_val_predict(DummyClassifier(strategy="most_frequent"), X, y, cv=cv)
    assert recall_score(y, pred) == 0.0, "95% accurate and it catches NOTHING"

    print(f"\n  accuracy paradox: ZeroR scores {base:.4f} accuracy "
          f"with {recall_score(y, pred):.4f} recall")
    print(f"       95% accuracy, zero minority-class detections -- which is why")
    print(f"       accuracy alone is worse than useless on imbalanced data")


def rule_ordering_matters():
    """When rules are NOT mutually exclusive, conflict resolution decides."""
    overlapping = [
        ({"Outlook": "Sunny"},                     "No"),    # general
        ({"Outlook": "Sunny", "Humidity": "Normal"}, "Yes"),  # specific
    ]
    row = {"Outlook": "Sunny", "Temperature": "Cool",
           "Humidity": "Normal", "Wind": "Weak", "Play": "Yes"}

    fired = [(a, c) for a, c in overlapping if matches(row, a)]
    assert len(fired) == 2, "this record fires BOTH rules"

    first_match = fired[0][1]                          # rule ordering
    most_specific = max(fired, key=lambda p: len(p[0]))[1]   # size ordering

    assert first_match == "No" and most_specific == "Yes"
    assert row["Play"] == "Yes", "size ordering gets it right here"

    print(f"\n  conflict: a record fires both rules")
    print(f"       rule ordering (first match) -> {first_match}")
    print(f"       size ordering (most specific) -> {most_specific}   <- correct")


def main():
    print("Experiment 13 -- Rule-based classification")
    # Step 1: Write the rules, and measure their coverage and accuracy
    unit4_rules()
    # Step 2: Check that they classify every day correctly
    rules_predict_correctly()
    # Step 3: Run ZeroR first, as the baseline
    zeror_baseline()
    # Step 4: See the accuracy paradox on imbalanced data
    accuracy_paradox_in_one_click()
    # Step 5: Order rules that overlap
    rule_ordering_matters()
    print("\n  rule-based classification verified")


if __name__ == "__main__":
    main()

5. Execution and Results

In WEKA, from the command line, 13_rules_weka.sh:

OUTPUT


$ java weka.classifiers.rules.ZeroR -t data/weather.nominal.arff -o

Time taken to test model on training data: 0 seconds

=== Error on training data ===

Correctly Classified Instances           9               64.2857 %
Incorrectly Classified Instances         5               35.7143 %
Kappa statistic                          0
Mean absolute error                      0.4643
Root mean squared error                  0.4795
Relative absolute error                100      %
Root relative squared error            100      %
Total Number of Instances               14


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 1.000    1.000    0.643      1.000    0.783      ?        0.500     0.643     yes
                 0.000    0.000    ?          0.000    ?          ?        0.500     0.357     no
Weighted Avg.    0.643    0.643    ?          0.643    ?          ?        0.500     0.541


=== Confusion Matrix ===

 a b   <-- classified as
 9 0 | a = yes
 5 0 | b = no

Time taken to perform cross-validation: 0 seconds


=== Stratified cross-validation ===

Correctly Classified Instances           9               64.2857 %
Incorrectly Classified Instances         5               35.7143 %
Kappa statistic                          0
Mean absolute error                      0.4762
Root mean squared error                  0.4934
Relative absolute error                100      %
Root relative squared error            100      %
Total Number of Instances               14


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 1.000    1.000    0.643      1.000    0.783      ?        0.178     0.555     yes
                 0.000    0.000    ?          0.000    ?          ?        0.178     0.318     no
Weighted Avg.    0.643    0.643    ?          0.643    ?          ?        0.178     0.470


=== Confusion Matrix ===

 a b   <-- classified as
 9 0 | a = yes
 5 0 | b = no


$ java weka.classifiers.rules.OneR -t data/weather.nominal.arff

=== Classifier model (full training set) ===

outlook:
    sunny   -> no
    overcast    -> yes
    rainy   -> yes
(10/14 instances correct)


Time taken to build model: 0.06 seconds

Time taken to test model on training data: 0 seconds

=== Error on training data ===

Correctly Classified Instances          10               71.4286 %
Incorrectly Classified Instances         4               28.5714 %
Kappa statistic                          0.3778
Mean absolute error                      0.2857
Root mean squared error                  0.5345
Relative absolute error                 61.5385 %
Root relative squared error            111.4773 %
Total Number of Instances               14


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 0.778    0.400    0.778      0.778    0.778      0.378    0.689     0.748     yes
                 0.600    0.222    0.600      0.600    0.600      0.378    0.689     0.503     no
Weighted Avg.    0.714    0.337    0.714      0.714    0.714      0.378    0.689     0.660


=== Confusion Matrix ===

 a b   <-- classified as
 7 2 | a = yes
 2 3 | b = no

Time taken to perform cross-validation: 0.01 seconds


=== Stratified cross-validation ===

Correctly Classified Instances           6               42.8571 %
Incorrectly Classified Instances         8               57.1429 %
Kappa statistic                         -0.1429
Mean absolute error                      0.5714
Root mean squared error                  0.7559
Relative absolute error                120      %
Root relative squared error            153.2194 %
Total Number of Instances               14


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 0.444    0.600    0.571      0.444    0.500      -0.149   0.422     0.611     yes
                 0.400    0.556    0.286      0.400    0.333      -0.149   0.422     0.329     no
Weighted Avg.    0.429    0.584    0.469      0.429    0.440      -0.149   0.422     0.510


=== Confusion Matrix ===

 a b   <-- classified as
 4 5 | a = yes
 3 2 | b = no


$ java weka.classifiers.rules.JRip -F 3 -N 2.0 -t data/weather.nominal.arff

Options: -F 3 -N 2.0

=== Classifier model (full training set) ===

JRIP rules:
===========

(humidity = high) and (outlook = sunny) => play=no (3.0/0.0)
(outlook = rainy) and (windy = TRUE) => play=no (2.0/0.0)
 => play=yes (9.0/0.0)

Number of Rules : 3


Time taken to build model: 0.07 seconds

Time taken to test model on training data: 0 seconds

=== Error on training data ===

Correctly Classified Instances          14              100      %
Incorrectly Classified Instances         0                0      %
Kappa statistic                          1
Mean absolute error                      0
Root mean squared error                  0
Relative absolute error                  0      %
Root relative squared error              0      %
Total Number of Instances               14


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 1.000    0.000    1.000      1.000    1.000      1.000    1.000     1.000     yes
                 1.000    0.000    1.000      1.000    1.000      1.000    1.000     1.000     no
Weighted Avg.    1.000    0.000    1.000      1.000    1.000      1.000    1.000     1.000


=== Confusion Matrix ===

 a b   <-- classified as
 9 0 | a = yes
 0 5 | b = no

Time taken to perform cross-validation: 0.01 seconds


=== Stratified cross-validation ===

Correctly Classified Instances           9               64.2857 %
Incorrectly Classified Instances         5               35.7143 %
Kappa statistic                          0.186
Mean absolute error                      0.3674
Root mean squared error                  0.5338
Relative absolute error                 77.1635 %
Root relative squared error            108.1927 %
Total Number of Instances               14


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 0.778    0.600    0.700      0.778    0.737      0.189    0.656     0.757     yes
                 0.400    0.222    0.500      0.400    0.444      0.189    0.656     0.646     no
Weighted Avg.    0.643    0.465    0.629      0.643    0.632      0.189    0.656     0.717


=== Confusion Matrix ===

 a b   <-- classified as
 7 2 | a = yes
 3 2 | b = no


$ java weka.classifiers.rules.PART -t data/weather.nominal.arff

=== Classifier model (full training set) ===

PART decision list
------------------

outlook = overcast: yes (4.0)

humidity = high: no (5.0/1.0)

: yes (5.0/1.0)

Number of Rules  :  3


Time taken to build model: 0.08 seconds

Time taken to test model on training data: 0 seconds

=== Error on training data ===

Correctly Classified Instances          12               85.7143 %
Incorrectly Classified Instances         2               14.2857 %
Kappa statistic                          0.6889
Mean absolute error                      0.2286
Root mean squared error                  0.3381
Relative absolute error                 49.2308 %
Root relative squared error             70.5044 %
Total Number of Instances               14


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 0.889    0.200    0.889      0.889    0.889      0.689    0.889     0.911     yes
                 0.800    0.111    0.800      0.800    0.800      0.689    0.889     0.740     no
Weighted Avg.    0.857    0.168    0.857      0.857    0.857      0.689    0.889     0.850


=== Confusion Matrix ===

 a b   <-- classified as
 8 1 | a = yes
 1 4 | b = no

Time taken to perform cross-validation: 0.01 seconds


=== Stratified cross-validation ===

Correctly Classified Instances           8               57.1429 %
Incorrectly Classified Instances         6               42.8571 %
Kappa statistic                          0.0667
Mean absolute error                      0.4369
Root mean squared error                  0.5576
Relative absolute error                 91.75   %
Root relative squared error            113.0155 %
Total Number of Instances               14


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 0.667    0.600    0.667      0.667    0.667      0.067    0.589     0.688     yes
                 0.400    0.333    0.400      0.400    0.400      0.067    0.589     0.437     no
Weighted Avg.    0.571    0.505    0.571      0.571    0.571      0.067    0.589     0.598


=== Confusion Matrix ===

 a b   <-- classified as
 6 3 | a = yes
 3 2 | b = no

In Python, 13_rules.py:

OUTPUT

Experiment 13 -- Rule-based classification
  the five tree paths as rules (Unit 4 section 4.10)
    IF Outlook=Overcast                       THEN Yes  coverage 0.2857 ( 4/14)  accuracy 1.0000
    IF Outlook=Sunny AND Humidity=Normal      THEN Yes  coverage 0.1429 ( 2/14)  accuracy 1.0000
    IF Outlook=Sunny AND Humidity=High        THEN No   coverage 0.2143 ( 3/14)  accuracy 1.0000
    IF Outlook=Rain AND Wind=Weak             THEN Yes  coverage 0.2143 ( 3/14)  accuracy 1.0000
    IF Outlook=Rain AND Wind=Strong           THEN No   coverage 0.1429 ( 2/14)  accuracy 1.0000
       exhaustive (14/14 covered) and mutually exclusive (1 rule each)
       -- automatic, because tree paths PARTITION the space
  rule-set accuracy on the training data: 1.0000

  ZeroR baseline (WEKA: rules/ZeroR)
    weather: always predict 'Yes' -> 0.6429
    iris           ZeroR 0.3333   tree 0.9400   (+0.6067)
    breast cancer  ZeroR 0.6274   tree 0.9226   (+0.2952)

  accuracy paradox: ZeroR scores 0.9500 accuracy with 0.0000 recall
       95% accuracy, zero minority-class detections -- which is why
       accuracy alone is worse than useless on imbalanced data

  conflict: a record fires both rules
       rule ordering (first match) -> No
       size ordering (most specific) -> Yes   <- correct

  rule-based classification verified

ZeroR says "yes" every time and scores 64%. OneR's rule is on outlook; JRip learns two rules for "no" — sunny and humid, rainy and windy — and "yes" otherwise; PART a three-rule list. Cross-validated on 14 days, none beats ZeroR: OneR gets 6, JRip 9, PART 8. That is the baseline's lesson in its plainest form.

The Python half extracts rules from a decision tree (Unit 4 §4.10's five weather rules), computes each rule's coverage and accuracy, and compares against a DummyClassifier(strategy='most_frequent') — the scikit-learn ZeroR.

RESULT

On the weather data no rule learner beats ZeroR's 64% by cross-validation; the rules themselves match the tree's.

Experiment 14 — Compare classifiers: confusion matrix, accuracy, ROC

1. Question

Compare several classifiers by their confusion matrices, accuracy and ROC.

2. Aim

Evaluate five classifiers the same way, and read accuracy, the confusion matrix and the ROC area.

3. Steps

In WEKA, from the command line, 14_compare_weka.sh:

  1. Five classifiers on the same data, the same 10 folds (seed 1): accuracy, the confusion matrix, ROC area.

In Python, 14_compare.py:

  1. Read the spam filter's confusion matrix.
  2. Work Practice Problem 2's base rate.
  3. Compare five classifiers.
  4. Test whether the differences are significant.
  5. Compare them by ROC and AUC.

IN THE EXPLORER

  1. Run J48, NaiveBayes, IBk (k-NN), JRip and ZeroR on the same data with the same 10-fold cross-validation and the same seed.

  2. For each, record accuracy, precision, recall, F-measure and ROC area.

  3. Right-click a result → Visualize threshold curve → select the positive class for the ROC curve. The AUC is printed in its title bar.

  4. Use the Experimenter with the Paired T-Tester for significance.

4. Programme

In WEKA, from the command line, 14_compare_weka.sh:

#!/usr/bin/env bash
# Experiment 14 in WEKA 3.8.7, from the command line: compare classifiers.
# Each command runs the WEKA class that the Explorer runs for the same choice, and prints
# what the Explorer shows in its output pane. The click-path is on the lab page.
# Needs tools/data-science/setup_weka.sh; run as  bash 14_compare_weka.sh
set -euo pipefail
source "$(dirname "$0")/weka.sh"

# Step 1: Five classifiers on the same data, the same 10 folds (seed 1): accuracy, the confusion matrix, ROC area
for c in weka.classifiers.rules.ZeroR weka.classifiers.trees.J48 weka.classifiers.bayes.NaiveBayes weka.classifiers.lazy.IBk weka.classifiers.rules.JRip; do
  weka $c -t $DATA/vote.arff -x 10 -s 1 -o
done

In Python, 14_compare.py:

"""Experiment 14 — Compare classifiers: confusion matrix, accuracy, ROC/AUC.

WEKA: run each classifier under the same cross-validation, then use the
Experimenter's Paired T-Tester. Visualize threshold curve gives the ROC.

Reproduces Unit 4 section 4.9's spam matrix and Practice Problem 2's medical
screening example -- the base rate fallacy -- exactly.
"""
import numpy as np
from scipy import stats
from sklearn.datasets import load_breast_cancer
from sklearn.dummy import DummyClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (accuracy_score, precision_score, recall_score,
                             f1_score, confusion_matrix, roc_auc_score, roc_curve)
from sklearn.model_selection import (StratifiedKFold, cross_val_score,
                                     cross_val_predict)
from sklearn.naive_bayes import GaussianNB
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.tree import DecisionTreeClassifier


def metrics_from_counts(tp, fp, fn, tn):
    total = tp + fp + fn + tn
    precision = tp / (tp + fp) if tp + fp else float("nan")
    recall = tp / (tp + fn) if tp + fn else float("nan")
    return {
        "accuracy": (tp + tn) / total,
        "precision": precision,
        "recall": recall,
        "specificity": tn / (tn + fp) if tn + fp else float("nan"),
        "f1": 2 * precision * recall / (precision + recall)
              if precision + recall else 0.0,
        "fpr": fp / (fp + tn) if fp + tn else float("nan"),
    }


def unit4_spam_matrix():
    """Section 4.9: 1000 emails, 200 spam, 180 flagged, 150 correctly."""
    tp, fp, fn, tn = 150, 30, 50, 770
    assert tp + fn == 200 and fp + tn == 800 and tp + fp == 180

    m = metrics_from_counts(tp, fp, fn, tn)
    assert round(m["accuracy"], 4) == 0.92
    assert round(m["precision"], 4) == 0.8333
    assert round(m["recall"], 4) == 0.75
    assert round(m["specificity"], 4) == 0.9625
    assert round(m["f1"], 4) == 0.7895
    assert round(m["fpr"], 4) == 0.0375

    print(f"  4.9 spam: accuracy {m['accuracy']:.4f} looks fine, but recall "
          f"{m['recall']:.4f}")
    print(f"       means 1 spam in 4 reaches the inbox, and precision "
          f"{m['precision']:.4f}")
    print(f"       means 17% of the spam folder is REAL email")


def practice_2_base_rate():
    """Practice Problem 2: 1% prevalence, 95% sensitivity, 90% specificity."""
    diseased, healthy = 100, 9900
    tp = round(0.95 * diseased)
    fn = diseased - tp
    tn = round(0.90 * healthy)
    fp = healthy - tn
    assert (tp, fn, tn, fp) == (95, 5, 8910, 990)

    m = metrics_from_counts(tp, fp, fn, tn)
    assert round(m["accuracy"], 4) == 0.9005
    assert round(m["precision"], 4) == 0.0876
    assert round(m["recall"], 4) == 0.95
    # 0.1603 from exact counts. The notes show 0.1604 because they combine the
    # 4-decimal rounded precision and recall, which is what you do by hand.
    assert round(m["f1"], 4) == 0.1603, m["f1"]

    print(f"\n  Practice 2 base rate fallacy:")
    print(f"       accuracy {m['accuracy']:.4f}, recall {m['recall']:.4f} -- "
          f"both excellent")
    print(f"       precision {m['precision']:.4f} -- fewer than 1 positive in 11")
    print(f"       is real, because 10% of 9900 healthy people is {fp} false alarms")
    print(f"       F1 {m['f1']:.4f} reflects what accuracy conceals")


def compare_five():
    """Five classifiers, one stratified 10-fold split, one table."""
    X, y = load_breast_cancer(return_X_y=True)
    cv = StratifiedKFold(n_splits=10, shuffle=True, random_state=42)

    models = {
        "ZeroR":      DummyClassifier(strategy="most_frequent"),
        "NaiveBayes": GaussianNB(),
        "J48 (tree)": DecisionTreeClassifier(random_state=0),
        "IBk (k-NN)": make_pipeline(StandardScaler(), KNeighborsClassifier(5)),
        "RandomForest": RandomForestClassifier(n_estimators=100, random_state=0),
    }

    print("\n  classifier comparison, stratified 10-fold CV")
    print("    model          acc     prec    recall   F1      AUC")
    results = {}
    for name, model in models.items():
        pred = cross_val_predict(model, X, y, cv=cv)
        scores = cross_val_score(model, X, y, cv=cv)
        try:
            proba = cross_val_predict(model, X, y, cv=cv, method="predict_proba")[:, 1]
            auc = roc_auc_score(y, proba)
        except Exception:
            auc = float("nan")
        results[name] = scores
        print(f"    {name:14s} {accuracy_score(y, pred):.4f}  "
              f"{precision_score(y, pred, zero_division=0):.4f}  "
              f"{recall_score(y, pred):.4f}  {f1_score(y, pred):.4f}  {auc:.4f}")

    for name, scores in results.items():
        if name != "ZeroR":
            assert scores.mean() > results["ZeroR"].mean(), \
                f"{name} must beat the ZeroR baseline"

    return results


def paired_significance(results):
    """A 2% difference on one split is noise. Test it properly."""
    print("\n  paired t-tests against RandomForest (WEKA: Paired T-Tester)")
    best = results["RandomForest"]
    for name, scores in results.items():
        if name == "RandomForest":
            continue
        t, p = stats.ttest_rel(best, scores)
        verdict = "significantly better" if p < 0.05 else "NOT significantly different"
        print(f"    vs {name:14s} diff {best.mean() - scores.mean():+.4f}  "
              f"p = {p:.4f}  -> {verdict}")


def roc_is_threshold_independent():
    """AUC measures RANKING, separately from where you cut."""
    X, y = load_breast_cancer(return_X_y=True)
    cv = StratifiedKFold(n_splits=10, shuffle=True, random_state=42)
    proba = cross_val_predict(LogisticRegression(max_iter=5000), X, y, cv=cv,
                              method="predict_proba")[:, 1]

    auc = roc_auc_score(y, proba)
    assert auc > 0.98, auc

    print(f"\n  ROC/AUC: {auc:.4f} -- one number, every threshold")
    print("    threshold  precision  recall")
    for t in (0.10, 0.30, 0.50, 0.70, 0.90):
        pred = (proba >= t).astype(int)
        print(f"      {t:.2f}      {precision_score(y, pred, zero_division=0):.4f}    "
              f"{recall_score(y, pred):.4f}")

    lo = (proba >= 0.10).astype(int)
    hi = (proba >= 0.90).astype(int)
    assert recall_score(y, lo) > recall_score(y, hi), "lower threshold, higher recall"
    assert precision_score(y, hi) > precision_score(y, lo), "higher threshold, higher precision"

    print("       the precision/recall trade-off, made concrete: the AUC is")
    print("       unchanged by the threshold, which is exactly its value")


def main():
    print("Experiment 14 -- Comparing classifiers")
    # Step 1: Read the spam filter's confusion matrix
    unit4_spam_matrix()
    # Step 2: Work Practice Problem 2's base rate
    practice_2_base_rate()
    # Step 3: Compare five classifiers
    results = compare_five()
    # Step 4: Test whether the differences are significant
    paired_significance(results)
    # Step 5: Compare them by ROC and AUC
    roc_is_threshold_independent()
    print("\n  all Unit 4 evaluation calculations reproduced")


if __name__ == "__main__":
    main()

5. Execution and Results

In WEKA, from the command line, 14_compare_weka.sh:

OUTPUT


$ java weka.classifiers.rules.ZeroR -t data/vote.arff -x 10 -s 1 -o

Time taken to test model on training data: 0 seconds

=== Error on training data ===

Correctly Classified Instances         267               61.3793 %
Incorrectly Classified Instances       168               38.6207 %
Kappa statistic                          0
Mean absolute error                      0.4742
Root mean squared error                  0.4869
Relative absolute error                100      %
Root relative squared error            100      %
Total Number of Instances              435


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 1.000    1.000    0.614      1.000    0.761      ?        0.500     0.614     democrat
                 0.000    0.000    ?          0.000    ?          ?        0.500     0.386     republican
Weighted Avg.    0.614    0.614    ?          0.614    ?          ?        0.500     0.526


=== Confusion Matrix ===

   a   b   <-- classified as
 267   0 |   a = democrat
 168   0 |   b = republican

Time taken to perform cross-validation: 0.01 seconds


=== Stratified cross-validation ===

Correctly Classified Instances         267               61.3793 %
Incorrectly Classified Instances       168               38.6207 %
Kappa statistic                          0
Mean absolute error                      0.4742
Root mean squared error                  0.4869
Relative absolute error                100      %
Root relative squared error            100      %
Total Number of Instances              435


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 1.000    1.000    0.614      1.000    0.761      ?        0.491     0.609     democrat
                 0.000    0.000    ?          0.000    ?          ?        0.491     0.382     republican
Weighted Avg.    0.614    0.614    ?          0.614    ?          ?        0.491     0.521


=== Confusion Matrix ===

   a   b   <-- classified as
 267   0 |   a = democrat
 168   0 |   b = republican


$ java weka.classifiers.trees.J48 -t data/vote.arff -x 10 -s 1 -o

Time taken to test model on training data: 0 seconds

=== Error on training data ===

Correctly Classified Instances         423               97.2414 %
Incorrectly Classified Instances        12                2.7586 %
Kappa statistic                          0.9418
Mean absolute error                      0.0519
Root mean squared error                  0.1506
Relative absolute error                 10.9481 %
Root relative squared error             30.9353 %
Total Number of Instances              435


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 0.978    0.036    0.978      0.978    0.978      0.942    0.986     0.987     democrat
                 0.964    0.022    0.964      0.964    0.964      0.942    0.986     0.970     republican
Weighted Avg.    0.972    0.031    0.972      0.972    0.972      0.942    0.986     0.981


=== Confusion Matrix ===

   a   b   <-- classified as
 261   6 |   a = democrat
   6 162 |   b = republican

Time taken to perform cross-validation: 0.04 seconds


=== Stratified cross-validation ===

Correctly Classified Instances         419               96.3218 %
Incorrectly Classified Instances        16                3.6782 %
Kappa statistic                          0.9224
Mean absolute error                      0.0611
Root mean squared error                  0.1748
Relative absolute error                 12.887  %
Root relative squared error             35.9085 %
Total Number of Instances              435


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 0.970    0.048    0.970      0.970    0.970      0.922    0.971     0.965     democrat
                 0.952    0.030    0.952      0.952    0.952      0.922    0.971     0.947     republican
Weighted Avg.    0.963    0.041    0.963      0.963    0.963      0.922    0.971     0.958


=== Confusion Matrix ===

   a   b   <-- classified as
 259   8 |   a = democrat
   8 160 |   b = republican


$ java weka.classifiers.bayes.NaiveBayes -t data/vote.arff -x 10 -s 1 -o

Time taken to test model on training data: 0.01 seconds

=== Error on training data ===

Correctly Classified Instances         393               90.3448 %
Incorrectly Classified Instances        42                9.6552 %
Kappa statistic                          0.7999
Mean absolute error                      0.0975
Root mean squared error                  0.2944
Relative absolute error                 20.555  %
Root relative squared error             60.469  %
Total Number of Instances              435


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 0.891    0.077    0.948      0.891    0.919      0.802    0.974     0.984     democrat
                 0.923    0.109    0.842      0.923    0.881      0.802    0.974     0.960     republican
Weighted Avg.    0.903    0.089    0.907      0.903    0.904      0.802    0.974     0.975


=== Confusion Matrix ===

   a   b   <-- classified as
 238  29 |   a = democrat
  13 155 |   b = republican

Time taken to perform cross-validation: 0.01 seconds


=== Stratified cross-validation ===

Correctly Classified Instances         392               90.1149 %
Incorrectly Classified Instances        43                9.8851 %
Kappa statistic                          0.7949
Mean absolute error                      0.0995
Root mean squared error                  0.2977
Relative absolute error                 20.9815 %
Root relative squared error             61.1406 %
Total Number of Instances              435


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 0.891    0.083    0.944      0.891    0.917      0.797    0.973     0.984     democrat
                 0.917    0.109    0.842      0.917    0.877      0.797    0.973     0.957     republican
Weighted Avg.    0.901    0.093    0.905      0.901    0.902      0.797    0.973     0.973


=== Confusion Matrix ===

   a   b   <-- classified as
 238  29 |   a = democrat
  14 154 |   b = republican


$ java weka.classifiers.lazy.IBk -t data/vote.arff -x 10 -s 1 -o

Time taken to test model on training data: 0.04 seconds

=== Error on training data ===

Correctly Classified Instances         434               99.7701 %
Incorrectly Classified Instances         1                0.2299 %
Kappa statistic                          0.9951
Mean absolute error                      0.0049
Root mean squared error                  0.0404
Relative absolute error                  1.04   %
Root relative squared error              8.2944 %
Total Number of Instances              435


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 1.000    0.006    0.996      1.000    0.998      0.995    1.000     1.000     democrat
                 0.994    0.000    1.000      0.994    0.997      0.995    1.000     1.000     republican
Weighted Avg.    0.998    0.004    0.998      0.998    0.998      0.995    1.000     1.000


=== Confusion Matrix ===

   a   b   <-- classified as
 267   0 |   a = democrat
   1 167 |   b = republican

Time taken to perform cross-validation: 0.04 seconds


=== Stratified cross-validation ===

Correctly Classified Instances         402               92.4138 %
Incorrectly Classified Instances        33                7.5862 %
Kappa statistic                          0.8423
Mean absolute error                      0.073
Root mean squared error                  0.242
Relative absolute error                 15.3852 %
Root relative squared error             49.708  %
Total Number of Instances              435


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 0.914    0.060    0.961      0.914    0.937      0.844    0.965     0.973     democrat
                 0.940    0.086    0.873      0.940    0.905      0.844    0.965     0.934     republican
Weighted Avg.    0.924    0.070    0.927      0.924    0.925      0.844    0.965     0.958


=== Confusion Matrix ===

   a   b   <-- classified as
 244  23 |   a = democrat
  10 158 |   b = republican


$ java weka.classifiers.rules.JRip -t data/vote.arff -x 10 -s 1 -o

Time taken to test model on training data: 0.01 seconds

=== Error on training data ===

Correctly Classified Instances         420               96.5517 %
Incorrectly Classified Instances        15                3.4483 %
Kappa statistic                          0.9277
Mean absolute error                      0.0615
Root mean squared error                  0.1753
Relative absolute error                 12.9584 %
Root relative squared error             36.0022 %
Total Number of Instances              435


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 0.963    0.030    0.981      0.963    0.972      0.928    0.976     0.975     democrat
                 0.970    0.037    0.942      0.970    0.956      0.928    0.976     0.956     republican
Weighted Avg.    0.966    0.033    0.966      0.966    0.966      0.928    0.976     0.968


=== Confusion Matrix ===

   a   b   <-- classified as
 257  10 |   a = democrat
   5 163 |   b = republican

Time taken to perform cross-validation: 0.08 seconds


=== Stratified cross-validation ===

Correctly Classified Instances         415               95.4023 %
Incorrectly Classified Instances        20                4.5977 %
Kappa statistic                          0.9037
Mean absolute error                      0.0838
Root mean squared error                  0.2093
Relative absolute error                 17.6705 %
Root relative squared error             42.9945 %
Total Number of Instances              435


=== Detailed Accuracy By Class ===

                 TP Rate  FP Rate  Precision  Recall   F-Measure  MCC      ROC Area  PRC Area  Class
                 0.951    0.042    0.973      0.951    0.962      0.904    0.942     0.946     democrat
                 0.958    0.049    0.925      0.958    0.942      0.904    0.942     0.887     republican
Weighted Avg.    0.954    0.044    0.955      0.954    0.954      0.904    0.942     0.924


=== Confusion Matrix ===

   a   b   <-- classified as
 254  13 |   a = democrat
   7 161 |   b = republican

In Python, 14_compare.py:

OUTPUT

Experiment 14 -- Comparing classifiers
  4.9 spam: accuracy 0.9200 looks fine, but recall 0.7500
       means 1 spam in 4 reaches the inbox, and precision 0.8333
       means 17% of the spam folder is REAL email

  Practice 2 base rate fallacy:
       accuracy 0.9005, recall 0.9500 -- both excellent
       precision 0.0876 -- fewer than 1 positive in 11
       is real, because 10% of 9900 healthy people is 990 false alarms
       F1 0.1603 reflects what accuracy conceals

  classifier comparison, stratified 10-fold CV
    model          acc     prec    recall   F1      AUC
    ZeroR          0.6274  0.6274  1.0000  0.7711  0.5000
    NaiveBayes     0.9367  0.9350  0.9664  0.9504  0.9864
    J48 (tree)     0.9192  0.9356  0.9356  0.9356  0.9135
    IBk (k-NN)     0.9701  0.9595  0.9944  0.9766  0.9862
    RandomForest   0.9578  0.9664  0.9664  0.9664  0.9885

  paired t-tests against RandomForest (WEKA: Paired T-Tester)
    vs ZeroR          diff +0.3305  p = 0.0000  -> significantly better
    vs NaiveBayes     diff +0.0211  p = 0.0656  -> NOT significantly different
    vs J48 (tree)     diff +0.0388  p = 0.0003  -> significantly better
    vs IBk (k-NN)     diff -0.0122  p = 0.1338  -> NOT significantly different

  ROC/AUC: 0.9903 -- one number, every threshold
    threshold  precision  recall
      0.10      0.9126    0.9944
      0.30      0.9380    0.9748
      0.50      0.9481    0.9720
      0.70      0.9686    0.9496
      0.90      0.9876    0.8908
       the precision/recall trade-off, made concrete: the AUC is
       unchanged by the threshold, which is exactly its value

  all Unit 4 evaluation calculations reproduced

On the vote data, by 10-fold cross-validation with seed 1:

Classifier Accuracy ROC area
ZeroR 61.4% 0.491
J48 96.3% 0.971
Naive Bayes 90.1% 0.973
IBk 92.4% 0.965
JRip 95.4% 0.942

Two things to say. IBk scores 99.8% on its training data and 92.4% cross-validated: one neighbour remembers every instance. And Naive Bayes has the best ROC area with the worst of the four accuracies — it ranks well and thresholds badly.

The Python half runs five classifiers under stratified 10-fold cross-validation, prints a full comparison table, computes ROC/AUC, and reproduces Unit 4 §4.9's spam confusion matrix (accuracy 0.920, precision 0.8333, recall 0.750, F1 0.7895) and Practice Problem 2's medical screening example (accuracy 0.9005 but precision only 0.0876) — the base rate fallacy, asserted.

RESULT

J48 is the most accurate on the vote data, 96.3%; Naive Bayes has the best ROC area, 0.973.

Experiment 15 — Text preprocessing, TF-IDF and K-Means

1. Question

Preprocess text into TF-IDF vectors, and cluster the documents.

2. Aim

Turn documents into word vectors in WEKA, weight them by TF-IDF, and cluster them.

3. Steps

In WEKA, from the command line, 15_text_clustering_weka.sh:

  1. Turn the documents into TF-IDF vectors (filter StringToWordVector).
  2. Cluster the vectors into 2 with K-Means, and compare with the class (SimpleKMeans).

In Python, 15_text_clustering.py:

  1. Check that a word in every document weighs zero.
  2. Compute TF-IDF by hand and with scikit-learn.
  3. Compare raw counts with TF-IDF.
  4. Cluster the documents.
  5. Cross-check with Course 6's TF-IDF.

IN THE EXPLORER

  1. Load a text dataset (ReutersCorn-train.arff, or build one with the TextDirectoryLoader).

  2. Preprocess → filters/unsupervised/attribute/StringToWordVector - IDFTransform = True, TFTransform = True → TF-IDF - lowerCaseTokens = True - stopwordsHandler = Rainbow (or supply a stopword file) - stemmer = IteratedLovinsStemmer or SnowballStemmer - wordsToKeep = 1000 - tokenizer = WordTokenizer (or NGramTokenizer for n-grams)

  3. Then Cluster → SimpleKMeans on the resulting vectors.

4. Programme

In WEKA, from the command line, 15_text_clustering_weka.sh:

#!/usr/bin/env bash
# Experiment 15 in WEKA 3.8.7, from the command line: text to TF-IDF vectors, then K-Means.
# Each command runs the WEKA class that the Explorer runs for the same choice, and prints
# what the Explorer shows in its output pane. The click-path is on the lab page.
# Needs tools/data-science/setup_weka.sh; run as  bash 15_text_clustering_weka.sh
set -euo pipefail
source "$(dirname "$0")/weka.sh"

# Step 1: Turn the documents into TF-IDF vectors (filter StringToWordVector)
# wordsToKeep is 100 here, not the click-path's 1000, so that K-Means's table of
# centroids, one row per word, fits on a page. It works the same either way.
weka weka.filters.unsupervised.attribute.StringToWordVector -T -I -L -W 100 -R first -i $DATA/ReutersCorn-train.arff -o reuters-tfidf.arff
grep -c "^@attribute" reuters-tfidf.arff

# Step 2: Cluster the vectors into 2 with K-Means, and compare with the class (SimpleKMeans)
weka weka.clusterers.SimpleKMeans -N 2 -S 10 -t reuters-tfidf.arff -c first   # the class comes first now

In Python, 15_text_clustering.py:

"""Experiment 15 — Text preprocessing, TF-IDF and K-Means.

WEKA: filters/unsupervised/attribute/StringToWordVector with IDFTransform and
TFTransform, then SimpleKMeans.

TF-IDF is implemented from first principles AND with scikit-learn, and the two
are asserted to agree. The result is also cross-checked against Course 6's
TF-IDF lab, so the two courses cannot drift apart.
"""
import math
import re
from collections import Counter

import numpy as np
from sklearn.cluster import KMeans
from sklearn.feature_extraction.text import TfidfVectorizer, CountVectorizer
from sklearn.metrics import silhouette_score

# Deliberately built so that "course" appears in EVERY document -- the term
# whose IDF must come out at exactly zero.
DOCUMENTS = [
    "the statistics course covers probability and distributions",
    "the course on data mining covers clustering and classification",
    "probability and statistics form the basis of this course",
    "clustering and classification are data mining topics in the course",
    "the python course covers data analysis and visualization",
    "data visualization and analysis with python in this course",
]

STOPWORDS = {"the", "and", "of", "on", "in", "this", "are", "with", "form", "a", "is"}


def tokenize(text):
    return [w for w in re.findall(r"[a-z]+", text.lower()) if w not in STOPWORDS]


def term_frequency(tokens):
    """TF as a proportion of the document's length."""
    counts = Counter(tokens)
    n = len(tokens)
    return {t: c / n for t, c in counts.items()}


def inverse_document_frequency(all_tokens):
    """Textbook IDF: log(N / df). A term in EVERY document gets exactly 0."""
    n = len(all_tokens)
    df = Counter()
    for tokens in all_tokens:
        df.update(set(tokens))
    return {t: math.log(n / d) for t, d in df.items()}


def tf_idf(documents):
    all_tokens = [tokenize(d) for d in documents]
    idf = inverse_document_frequency(all_tokens)
    return [{t: f * idf[t] for t, f in term_frequency(tk).items()} for tk in all_tokens], idf


def idf_of_a_universal_term_is_zero():
    """The property that MAKES TF-IDF work, asserted rather than asserted-in-prose."""
    matrices, idf = tf_idf(DOCUMENTS)

    all_tokens = [tokenize(d) for d in DOCUMENTS]
    universal = [t for t in idf if all(t in tk for tk in all_tokens)]
    assert universal == ["course"], universal
    assert idf["course"] == 0.0, idf["course"]

    # And every document's TF-IDF weight for it is therefore zero.
    for m in matrices:
        assert m["course"] == 0.0

    rare = min(idf, key=lambda t: -idf[t])
    print(f"  IDF: 'course' appears in all {len(DOCUMENTS)} documents -> "
          f"IDF exactly {idf['course']:.1f}")
    print(f"       a term carrying no discriminating information gets ZERO weight")
    print(f"       rarest term '{rare}' -> IDF {idf[rare]:.4f}")


def hand_and_sklearn_agree():
    """Different normalisation conventions, same RANKING -- which is what matters."""
    matrices, idf = tf_idf(DOCUMENTS)

    vec = TfidfVectorizer(stop_words=sorted(STOPWORDS), token_pattern=r"[a-z]+",
                          lowercase=True)
    X = vec.fit_transform(DOCUMENTS)
    vocab = vec.get_feature_names_out()

    # scikit-learn uses smoothed IDF (log((1+n)/(1+df)) + 1) and L2 row
    # normalisation, so the VALUES differ from the textbook formula. The
    # ordering of terms within a document is what both agree on.
    doc = 0
    hand_top = sorted(matrices[doc], key=lambda t: -matrices[doc][t])[:3]
    row = X[doc].toarray().ravel()
    sk_top = [vocab[i] for i in np.argsort(-row)[:3]]

    assert "course" not in hand_top, "a zero-weight term cannot be a top term"
    assert set(hand_top) & set(sk_top), \
        f"the two rankings must overlap: {hand_top} vs {sk_top}"

    print(f"\n  document 0 top terms")
    print(f"       by hand (log N/df):     {hand_top}")
    print(f"       scikit-learn (smoothed): {sk_top}")
    print(f"       values differ (smoothing + L2), the ranking agrees")


def raw_counts_versus_tfidf():
    """Why weight at all: raw counts let common words dominate."""
    counts = CountVectorizer(token_pattern=r"[a-z]+").fit(DOCUMENTS)
    totals = counts.transform(DOCUMENTS).sum(axis=0).A1
    names = counts.get_feature_names_out()
    commonest = names[totals.argmax()]

    assert commonest in {"the", "course", "and"}, commonest
    print(f"\n  raw counts: the commonest term is '{commonest}' "
          f"({totals.max()} occurrences)")
    print(f"       useless for telling the documents apart -- hence IDF")


def cluster_the_documents():
    """The three topics -- statistics, data mining, python -- should separate."""
    vec = TfidfVectorizer(stop_words=sorted(STOPWORDS), token_pattern=r"[a-z]+")
    X = vec.fit_transform(DOCUMENTS)

    km = KMeans(n_clusters=3, n_init=20, random_state=0).fit(X.toarray())
    labels = km.labels_.tolist()

    # Documents 0 and 2 are statistics; 1 and 3 are data mining; 4 and 5 python.
    for a, b in [(0, 2), (1, 3), (4, 5)]:
        assert labels[a] == labels[b], \
            f"documents {a} and {b} share a topic but landed in {labels[a]}/{labels[b]}"
    assert len(set(labels)) == 3, "all three clusters must be used"

    sil = silhouette_score(X.toarray(), km.labels_)
    assert sil > 0, sil

    print(f"\n  clustering: labels {labels}, silhouette {sil:.4f}")
    print(f"       (0,2) statistics, (1,3) data mining, (4,5) python -- recovered")

    terms = vec.get_feature_names_out()
    order = km.cluster_centers_.argsort()[:, ::-1]
    for i in range(3):
        top = [terms[j] for j in order[i, :4]]
        print(f"       cluster {i} top terms: {top}")


def course_6_cross_check():
    """Course 6's R lab computed TF-IDF on its own reviews. The FORMULA must
    give the same answer here -- this catches drift between the two courses."""
    reviews = [
        "the course was excellent and the material was clear",
        "excellent teaching but the course pace was fast",
        "the material was poor and the course was disorganised",
        "clear explanations excellent examples in the course",
        "poor pace and poor material throughout the course",
    ]
    matrices, idf = tf_idf(reviews)

    all_tokens = [tokenize(r) for r in reviews]
    assert all("course" in tk for tk in all_tokens)
    assert idf["course"] == 0.0, "the same universal-term property holds"

    # 'excellent' is in 3 of 5 -> IDF = ln(5/3)
    assert round(idf["excellent"], 6) == round(math.log(5 / 3), 6)
    # 'poor' is in 2 of 5 -> ln(5/2)
    assert round(idf["poor"], 6) == round(math.log(5 / 2), 6)
    assert idf["poor"] > idf["excellent"], "rarer term, higher weight"

    print(f"\n  Course 6 cross-check: IDF(excellent)={idf['excellent']:.4f}, "
          f"IDF(poor)={idf['poor']:.4f}, IDF(course)={idf['course']:.1f}")


def main():
    print("Experiment 15 -- Text preprocessing, TF-IDF and clustering")
    # Step 1: Check that a word in every document weighs zero
    idf_of_a_universal_term_is_zero()
    # Step 2: Compute TF-IDF by hand and with scikit-learn
    hand_and_sklearn_agree()
    # Step 3: Compare raw counts with TF-IDF
    raw_counts_versus_tfidf()
    # Step 4: Cluster the documents
    cluster_the_documents()
    # Step 5: Cross-check with Course 6's TF-IDF
    course_6_cross_check()
    print("\n  TF-IDF and text clustering verified")


if __name__ == "__main__":
    main()

5. Execution and Results

In WEKA, from the command line, 15_text_clustering_weka.sh:

OUTPUT


$ java weka.filters.unsupervised.attribute.StringToWordVector -T -I -L -W 100 -R first -i data/ReutersCorn-train.arff -o reuters-tfidf.arff
101

$ java weka.clusterers.SimpleKMeans -N 2 -S 10 -t reuters-tfidf.arff -c first


=== Clustering stats for training data ===


kMeans
======

Number of iterations: 7
Within cluster sum of squared errors: 20121.300080545727

Initial starting points (random):

Cluster 0: {37 0.621433,46 0.346242,56 0.527511}
Cluster 1: {0 0.049449,1 0.375518,6 1.417682,12 0.933789,13 1.228699,14 1.029293,19 1.185443,36 0.737823,47 0.892171,51 1.63928,53 1.306413,56 0.527511,57 0.887341,69 1.475178,70 1.099703,73 0.052329,77 1.471441,80 1.035226,92 0.787402}

Missing values globally replaced with mean/mode

Final cluster centroids:
                         Cluster#
Attribute    Full Data          0          1
              (1554.0)    (947.0)    (607.0)
============================================
&#3              0.046     0.0494     0.0407
&lt             0.2184     0.1784     0.2809
0               0.1824     0.1414     0.2464
000             0.2473     0.1801      0.352
1               0.2542     0.2069      0.328
10              0.1948      0.183     0.2131
12              0.1834     0.1347     0.2592
15              0.1829     0.1861     0.1779
1985            0.1678      0.183      0.144
1986            0.2372     0.2313     0.2465
1987            0.1634     0.2333     0.0542
2               0.2489      0.201     0.3235
3               0.2428     0.1814     0.3384
31              0.2087     0.1207     0.3461
4               0.2331     0.1902     0.3001
5               0.2412     0.2109     0.2883
6               0.2218     0.1641     0.3117
7               0.2171     0.1499      0.322
8               0.2098     0.1779     0.2595
9               0.2144     0.1602     0.2988
a               0.2245     0.3431     0.0395
about           0.2023     0.3251     0.0105
also            0.2048     0.3229     0.0207
an               0.242     0.3861     0.0171
and             0.2081     0.2894     0.0811
are              0.208     0.3362     0.0081
as              0.2159     0.3469     0.0116
at              0.2509     0.3986     0.0204
bank            0.1645     0.2403     0.0462
be              0.2429     0.3966     0.0031
been            0.1815     0.2918     0.0094
billion         0.1935     0.2368     0.1261
but             0.2195     0.3554     0.0075
by               0.249     0.3941     0.0226
company         0.2323     0.3352     0.0718
corp            0.2346     0.2263     0.2476
cts             0.2545     0.0748     0.5348
dlrs            0.2535     0.2638     0.2375
february        0.1432     0.2113     0.0369
for             0.2426     0.3435     0.0851
from            0.2549     0.3577     0.0945
group           0.1781      0.243     0.0768
had              0.196     0.3188     0.0044
has             0.2384     0.3839     0.0113
have            0.2026     0.3285     0.0063
he              0.1964     0.3195     0.0044
in              0.2101     0.2918     0.0827
inc             0.2463     0.2186     0.2896
is                0.25     0.3942     0.0249
it              0.2519     0.4071     0.0097
its             0.2538     0.4069     0.0151
january          0.154     0.2181      0.054
last            0.2034     0.3324     0.0021
loss            0.1984     0.0883     0.3702
march           0.2215     0.2109      0.238
market          0.1897     0.3069     0.0068
mln             0.2464     0.2295     0.2729
net             0.2467     0.0825     0.5029
new             0.1918     0.3019     0.0201
not             0.2295     0.3542      0.035
of              0.1724     0.2326     0.0786
oil              0.149     0.2056     0.0608
on              0.2548     0.3921     0.0405
one             0.2137     0.2602     0.1412
oper            0.0968          0     0.2478
or              0.2165     0.2727      0.129
pct             0.2468     0.3779     0.0423
per             0.1848     0.2259     0.1206
prices          0.1605     0.2517     0.0183
profit          0.1756     0.0841     0.3184
qtr              0.225     0.0232     0.5399
quarter         0.1378     0.1663     0.0933
record          0.1897     0.1129     0.3094
reuter          0.0485     0.0521     0.0429
revs            0.1976          0     0.5059
s               0.2497     0.3802     0.0462
said            0.2108     0.3385     0.0115
sales           0.1761     0.1647     0.1939
share           0.2098     0.2411     0.1609
shares          0.1756     0.2601     0.0437
shr             0.2325     0.0033     0.5901
stock           0.1861     0.2538     0.0806
that            0.2391     0.3923          0
the              0.206      0.325     0.0203
they            0.1678     0.2753          0
this            0.2128     0.3441     0.0079
to              0.2072     0.3087     0.0488
tonnes          0.1216     0.1794     0.0314
trade           0.1552     0.2444     0.0161
two             0.2015     0.2588     0.1121
u                0.229     0.3432     0.0509
up              0.1952      0.302     0.0285
vs              0.2528     0.0058     0.6382
was             0.2405     0.3865     0.0126
were            0.2059     0.3274     0.0165
which           0.2278     0.3636      0.016
will            0.2389     0.3869      0.008
with             0.249     0.3986     0.0155
would           0.2156     0.3526     0.0019
year             0.255     0.2467     0.2678


Clustered Instances

0       947 ( 61%)
1       607 ( 39%)


Class attribute: class-att
Classes to Clusters:

   0   1  <-- assigned to cluster
 911 598 | 0
  36   9 | 1

Cluster 0 <-- 0
Cluster 1 <-- 1

Incorrectly clustered instances :   634.0    40.7979 %

In Python, 15_text_clustering.py:

OUTPUT

Experiment 15 -- Text preprocessing, TF-IDF and clustering
  IDF: 'course' appears in all 6 documents -> IDF exactly 0.0
       a term carrying no discriminating information gets ZERO weight
       rarest term 'distributions' -> IDF 1.7918

  document 0 top terms
       by hand (log N/df):     ['distributions', 'statistics', 'probability']
       scikit-learn (smoothed): ['distributions', 'probability', 'statistics']
       values differ (smoothing + L2), the ranking agrees

  raw counts: the commonest term is 'and' (6 occurrences)
       useless for telling the documents apart -- hence IDF

  clustering: labels [2, 0, 2, 0, 1, 1], silhouette 0.4781
       (0,2) statistics, (1,3) data mining, (4,5) python -- recovered
       cluster 0 top terms: ['mining', 'classification', 'clustering', 'data']
       cluster 1 top terms: ['visualization', 'python', 'analysis', 'data']
       cluster 2 top terms: ['probability', 'statistics', 'basis', 'distributions']

  Course 6 cross-check: IDF(excellent)=0.5108, IDF(poor)=0.9163, IDF(course)=0.0

  TF-IDF and text clustering verified

StringToWordVector moves the class to the front, so the clusterer is told -c first. Two clusters do not find "about corn" and "not": 42% of the 1,554 articles fall on the wrong side, because K-Means groups articles by their commonest words, and those are not about corn. Only 45 of the articles are.

The Python half implements TF-IDF from first principles alongside scikit-learn's TfidfVectorizer, asserting the two agree, then clusters. It reproduces Data Science with R's TF-IDF lab result, so the two courses' answers are checked against each other.

The demonstration is built so that a term appearing in every document gets an IDF of exactly zero — the property that makes TF-IDF work — and the script asserts it rather than merely stating it.

RESULT

TF-IDF vectors cluster by topic in general, not by the one topic the class marks: 42% of the Reuters articles are on the wrong side.


Lab examination

The examiner gives you a dataset, an experiment number, and about an hour.

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

Load datasets and explore ARFF/CSV formats in Python

RUNS

Data cleaning and missing values in Python

RUNS

Normalization and discretization in Python

RUNS

Attribute selection and PCA in Python

RUNS

Summarize and visualize with class-wise comparison in Python

RUNS

Association rules with Apriori in Python

RUNS

Multilevel (generalized) association rules in Python

RUNS

K-Means clustering in Python

RUNS

Hierarchical clustering and dendrograms in Python

RUNS

EM clustering, compared with K-Means in Python

RUNS

Decision tree (WEKA's J48 is C4.5)

RUNS

Naive Bayes, and comparison with the decision tree in Python

RUNS

Rule-based classification in Python

RUNS

Compare classifiers: confusion matrix, accuracy, ROC/AUC in Python

RUNS

Text preprocessing, TF-IDF and K-Means in Python