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:
NN_name_weka.sh. Each command runs the WEKA
class the Explorer runs for that choice, and prints what the Explorer's output pane shows,
so its output is the output you will see.NN_name.py, which reproduces the hand-computed
values in Units 2–5 and asserts them, run by tools/data-science/run_data_labs.py.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.
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.
@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.
Load datasets in ARFF and CSV formats, and explore their attributes.
Load data into WEKA, read its attribute summary, and convert between ARFF and CSV.
In WEKA, from the command line, 01_load_explore_weka.sh:
In Python, 01_load_explore.py:
IN THE EXPLORER
data/weather.nominal.arffClick each attribute. The right pane shows, for a nominal attribute, the label counts; for a numeric one, minimum, maximum, mean and standard deviation.
Edit… opens the data as a table.
.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.
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()
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.
Clean a dataset that has missing values.
Find and replace missing values in WEKA, and see what mean imputation does to the data.
In WEKA, from the command line, 02_missing_values_weka.sh:
In Python, 02_missing_values.py:
IN THE EXPLORER
Load a dataset with missing values (labor.arff, or weather with ?
inserted).
Preprocess → Choose →
filters/unsupervised/attribute/ReplaceMissingValues → Apply.
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.
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()
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.
Normalise and discretise numeric attributes.
Scale attributes to a common range, and turn numbers into bins three ways.
In WEKA, from the command line, 03_normalize_discretize_weka.sh:
In Python, 03_normalize_discretize.py:
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
bins = 3useEqualFrequency = False → equal-width; True → equal-frequency'(-inf-52.5]', '(52.5-63)', '(63-inf)'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.
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()
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.
Select the most useful attributes, by a filter and by a wrapper, and reduce the data with PCA.
Rank and select attributes in WEKA, and transform them into principal components.
In WEKA, from the command line, 04_feature_selection_weka.sh:
In Python, 04_feature_selection.py:
IN THE EXPLORER
Filter approach: Select attributes tab
InfoGainAttributeEvalRankerWrapper approach:
WrapperSubsetEval (choose a classifier inside it)BestFirst or GreedyStepwisePCA: Preprocess →
filters/unsupervised/attribute/PrincipalComponents
varianceCovered = 0.95-0.581petallength-0.566petalwidth-0.522sepallength+0.263sepalwidthRead that attribute name carefully in the viva — it is the eigenvector, and it is exactly why PCA costs you interpretability.
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()
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.
Summarise a dataset and visualise it, comparing the classes.
Describe each attribute, and compare the classes on each.
In WEKA, from the command line, 05_summarize_weka.sh:
In Python, 05_summarize.py:
IN THE EXPLORER
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".
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()
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.
Mine association rules from transaction data with Apriori.
Find frequent itemsets and strong rules with Apriori, and read support, confidence and lift.
In WEKA, from the command line, 06_apriori_weka.sh:
In Python, 06_apriori.py:
IN THE EXPLORER
supermarket.arff (4,627 transactions, 217 items, sparse ARFF).Apriori.| 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.
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()
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.
Mine multilevel association rules over a product hierarchy.
Mine rules at more than one level of a hierarchy, and drop the redundant ones.
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:
Add ancestor attributes to each transaction: a basket containing
amul_milk also gets milk and dairy.
Run Apriori on the extended data.
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.
"""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()
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.
Cluster a dataset with K-Means, and compare the clusters with the classes.
Run K-Means in WEKA, read its centroids and error, and see the seed change the answer.
In WEKA, from the command line, 08_kmeans_weka.sh:
In Python, 08_kmeans.py:
IN THE EXPLORER
iris.arff.Preprocess → remove the class attribute
(filters/unsupervised/attribute/Remove, attributeIndices = last).
Clustering is unsupervised — leaving the class in is a form of leakage.
Cluster tab → Choose → SimpleKMeans
- numClusters = 3
- distanceFunction = EuclideanDistance
- seed = 10 (changing it changes the result — that is §5.2's weakness 2)
Cluster mode → Classes to clusters evaluation (re-select the class) to see how the clusters line up with the true species.
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.
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()
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.
Cluster a dataset hierarchically, and draw the dendrogram.
Build a hierarchical clustering in WEKA, and compare single and complete linkage.
In WEKA, from the command line, 09_hierarchical_weka.sh:
In Python, 09_hierarchical.py:
IN THE EXPLORER
HierarchicalClusterer
- numClusters = 3
- linkType = SINGLE / COMPLETE / AVERAGE / WARD / CENTROID / MEAN /
ADJCOMPLETE / NEIGHBOR_JOININGprintNewick = True to print the tree
2. Right-click the result in the Result list → Visualize tree for the
dendrogram. That step is easy to miss and is the whole point of the
experiment.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()
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.
Cluster a dataset with EM, and compare it with K-Means.
Fit a mixture model with EM, let it choose the number of clusters, and compare with K-Means.
In WEKA, from the command line, 10_em_clustering_weka.sh:
In Python, 10_em_clustering.py:
IN THE EXPLORER
EM
- numClusters = -1 → WEKA chooses k by cross-validation. That
automatic selection is EM's distinctive feature in WEKA and is worth
stating.maxIterations = 100
2. The output gives, per cluster and per attribute, the mean and standard
deviation (numeric) or the probability of each value (nominal), plus
the log likelihood.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.
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()
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.
Build a decision tree classifier with J48, and evaluate it.
Build and read a J48 tree, and evaluate it by cross-validation.
In WEKA, from the command line, 11_decision_tree_weka.sh:
In Python, 11_decision_tree.py:
IN THE EXPLORER
J48 is WEKA's implementation of C4.5. State that; it is a two-mark question.
weather.nominal.arff.trees/J48
- confidenceFactor = 0.25 — lower means more pruning
- minNumObj = 2 — minimum instances per leaf
- unpruned = False
- binarySplits = FalseRead 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.
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()
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%.
Classify with Naive Bayes, and compare it with the decision tree.
Build a Naive Bayes classifier in WEKA, and compare it with J48 fairly.
In WEKA, from the command line, 12_naive_bayes_weka.sh:
In Python, 12_naive_bayes.py:
IN THE EXPLORER
bayes/NaiveBayes
- useSupervisedDiscretization = True often helps on numeric dataThat 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.
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()
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.
Classify with rules, and compare them with a baseline.
Learn rules with OneR, JRip and PART, and measure them against ZeroR.
In WEKA, from the command line, 13_rules_weka.sh:
In Python, 13_rules.py:
IN THE EXPLORER
rules/JRip (this is RIPPER)
- folds = 3 — used for the pruning split
- minNo = 2Or rules/PART, which builds partial C4.5 trees and takes the best leaf as
a rule each round.
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.
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()
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.
Compare several classifiers by their confusion matrices, accuracy and ROC.
Evaluate five classifiers the same way, and read accuracy, the confusion matrix and the ROC area.
In WEKA, from the command line, 14_compare_weka.sh:
In Python, 14_compare.py:
IN THE EXPLORER
Run J48, NaiveBayes, IBk (k-NN), JRip and ZeroR on the same data with the same 10-fold cross-validation and the same seed.
For each, record accuracy, precision, recall, F-measure and ROC area.
Right-click a result → Visualize threshold curve → select the positive class for the ROC curve. The AUC is printed in its title bar.
Use the Experimenter with the Paired T-Tester for significance.
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()
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.
Preprocess text into TF-IDF vectors, and cluster the documents.
Turn documents into word vectors in WEKA, weight them by TF-IDF, and cluster them.
In WEKA, from the command line, 15_text_clustering_weka.sh:
In Python, 15_text_clustering.py:
IN THE EXPLORER
Load a text dataset (ReutersCorn-train.arff, or build one with the
TextDirectoryLoader).
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)
SimpleKMeans on the resulting vectors.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()
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)
============================================
 0.046 0.0494 0.0407
< 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.
The examiner gives you a dataset, an experiment number, and about an hour.
What costs marks:
(9/2) at a J48 leaf meansWhat earns them:
Use 10-fold stratified cross-validation and say why: one holdout split is high-variance, and stratification matters when classes are imbalanced.
Read the confusion matrix, not just the accuracy line, and say which error costs more in this application.
Use the Experimenter and a paired t-test when asked to compare.
When you set a parameter, say what it does — confidenceFactor = 0.25 means
more pruning at lower values, which is counter-intuitive and worth
demonstrating that you know.
Connect back to the theory: the root of your J48 tree should be the attribute with the highest information gain, and you can verify that by hand on the weather data in two minutes.
The same experiments, one page each, so a program can be reached by what it does rather than by its number.