Problems: High-dimensional Classification#
Get this problem set
Download everything (Topic3.4-High-dimensional_Classification_Problems.zip) — the notebook and
membranes_fabrication.csv, in a folder that is ready to run as-is.
The Download badge at the top of the page will also give you the notebook on its own. On Vocareum everything is already set up for you.
Before you start
This problem set accompanies High-dimensional Classification. It is worth 100 points:
Part A — Skill Checks (30 pts) — short answers, auto-graded, resubmit as often as you like until they pass.
Part B — Visualization (35 pts) — plots plus written interpretation, peer graded.
Part C — Open Ended (35 pts) — one synthesis problem, peer graded.
The parts build on each other: Part A works out the syntax you need for Part B, and Part B produces the evidence you argue from in Part C. Do them in order.
Setup#
data/membranes_fabrication.csv is a gas-separation membrane database: 1,672 records
covering more than 280 membranes, hand-extracted from 42 papers published between 2009 and
2022 (github.com/tenoriolms/database_membranes,
Apache-2.0). Each record says what the membrane was made of, how it was mounted, and the
conditions it was tested at.
The task here is a database audit rather than a prediction anyone needs to make. Every record already states its material family. The question is whether a classifier can recover that family from the fabrication and testing metadata alone. If it can, the records are not independent samples of chemistry: they carry the fingerprint of how each sub-field runs and reports its experiments, and any model trained on the database inherits it. This is worth asking of a curated literature dataset before trusting anything built on it.
The file has been prepared for that question. No permeability or permeance column is in
it. The columns that would give the answer away outright are also gone: subtype (47
values, not one shared between two families, so it is the label spelled differently),
filler_loading (non-zero for every mixed-matrix membrane and for nothing else), and the
material description.
source_ref, and why every split is by paper#
source_ref names the paper each row came from. It is not a feature — it is the
grouping key, and every split below depends on it.
The 1,672 rows came from 42 papers, and one paper contributes 134 of them: its own handful of
membranes, measured at many conditions. Split those rows at random and rows from the same
paper land on both sides, so a classifier can score well by recognizing the paper rather than
the chemistry. Every split below is therefore made by paper, with GroupShuffleSplit, and
no paper appears in both the training and the test set. Part C measures what that costs.
Two more preparation decisions, both forced by the data:
Four families, not six.
CMS(21 rows) andceramic(24 rows) each come from a single paper, so a split by paper puts all of either class entirely on one side and the class becomes untrainable. They are dropped, leaving polymer, zeolite, MMM and MOF.Missing values are imputed with the training-set median, inside the pipeline. The numeric columns are 16 to 99% complete — literature compilations record whatever each paper happened to report — and
SVCcannot take aNaN. Doing it inside the pipeline is what keeps the test rows out of the median.
%matplotlib inline
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import GroupShuffleSplit, GroupKFold, GridSearchCV
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.svm import SVC
from sklearn.metrics import confusion_matrix, classification_report
try:
plt.style.use('../settings/plot_style.mplstyle') # available inside the book
except OSError:
pass # downloaded notebook: use defaults
df_all = pd.read_csv('data/membranes_fabrication.csv')
FAMILIES = ['polymer', 'zeolite', 'MMM', 'MOF']
df = df_all[df_all['material_type'].isin(FAMILIES)].reset_index(drop=True)
NUMERIC = ['temperature_K', 'feed_pressure_Pa', 'permeate_pressure_Pa', 'delta_pressure_Pa',
'feed_flow_rate_mL_min', 'sweep_gas_flow_mL_min', 'stage_cut_pct',
'min_thickness', 'max_thickness', 'min_pore_size_nm', 'max_pore_size_nm',
'total_pore_volume_cc_g', 'micropore_volume_cc_g', 'specific_surface_area_m2_g',
'surface_area_cm2', 'aging_days']
CATEGORICAL = ['configuration', 'support_material', 'pore_size_type', 'sweep_gas',
'provided_data_type']
X = df[NUMERIC + CATEGORICAL]
y = df['material_type'].values
groups = df['source_ref'].values
train_idx, test_idx = next(
GroupShuffleSplit(n_splits=1, test_size=0.3, random_state=0).split(X, y, groups=groups))
X_train, X_test = X.iloc[train_idx], X.iloc[test_idx]
y_train, y_test = y[train_idx], y[test_idx]
groups_train = groups[train_idx]
print(f"{len(df_all)} records, {len(df)} in the four retained families")
print(df['material_type'].value_counts().to_string())
print(f"\n{len(train_idx)} training rows from {len(set(groups_train))} papers")
print(f"{len(test_idx)} test rows from {len(set(groups[test_idx]))} papers")
print(f"no paper on both sides: {set(groups_train).isdisjoint(set(groups[test_idx]))}")
print(f"test composition: {pd.Series(y_test).value_counts().to_dict()}")
1672 records, 1627 in the four retained families
material_type
polymer 603
zeolite 506
MMM 375
MOF 143
1281 training rows from 36 papers
346 test rows from 16 papers
no paper on both sides: True
test composition: {'zeolite': 140, 'polymer': 80, 'MMM': 70, 'MOF': 56}
One helper builds the pipeline, with the categorical branch switchable. Parts A and B both turn on that switch.
def make_pipeline(use_categorical, C=10.0, gamma=0.1, categorical=None):
"""RBF-kernel SVC on the numeric columns, optionally plus one-hot categoricals.
The numeric branch imputes with the training median and then standardizes; both are
fitted inside the pipeline, so the test rows never contribute to either.
"""
cats = CATEGORICAL if categorical is None else categorical
branches = [('num', Pipeline([('impute', SimpleImputer(strategy='median')),
('scale', StandardScaler())]), NUMERIC)]
if use_categorical and cats:
branches.append(('cat', OneHotEncoder(handle_unknown='ignore', sparse_output=False), cats))
return Pipeline([('prep', ColumnTransformer(branches)),
('clf', SVC(kernel='rbf', C=C, gamma=gamma))])
def n_features(pipe):
"""How many columns the fitted preprocessor actually produces."""
return pipe.named_steps['prep'].transform(X_train).shape[1]
print("make_pipeline ready")
make_pipeline ready
handle_unknown='ignore' is not optional here. The test papers are papers the encoder has
never seen, so they can easily report a support material or a sweep gas that appears nowhere
in the training rows; without it, transform would raise.
Run this once. It defines the check helper used after each Part A answer. It will
not run inside the book — use the downloaded notebook.
# Run this once. It defines `check`, used after each Part A answer below.
import hashlib
_EXPECTED = {
'q1': (4, ['09cb69a6e0d5', '81d0971f19d3', 'd72c71286bf8', '192160bf535f', 'ebc96010c6fc', '02d44139d45b', '167cd7e4f245', 'b7ba25a2f627', '8bb947280191', '8cc2062b35c6', '93473885b84e']),
'q2': (4, ['4f62466d8ec8', 'c40b0be84707', '88c288ebcc67', '06e0abe23cef', '143466381d8e', '359b7076a95a', 'eaee3e6ae664', '0f121a5d3e10', '800592cbbeef', '43f765f03bf7', '76bb34025409', '953800322c3e', '6e4a5cbbb332']),
'q3': (4, ['4b16dee212a6', 'd1ccb042876a', '0d58cbf3cc90', 'f0613dc0d593', '5d5d32037dc9', '87eb2ec0799b', '0895593323f9', '6d400c5ae1e3', '1a5b887be48d', 'bd1e16883bbc', '95bb49df2c33', 'd72aadfd7bcd', 'cd61eaf3e16f', '866fb79d1805', '8283afb503f3']),
}
def check(qid, value):
"""Compare an answer against a stored digest. Practice only -- nothing is recorded."""
places, digests = _EXPECTED[qid]
if value is Ellipsis:
print(f"{qid}: not answered yet")
return
got = hashlib.sha256(f"{float(value):.{places}f}".encode()).hexdigest()[:12]
print(f"{qid}: {'correct' if got in digests else 'not the expected value'}")
Part A — Skill Checks (30 pts)#
Three questions, 10 points each, and together they are one controlled experiment. A1 is the number a classifier has to beat. A2 and A3 then use the same split, the same kernel and the same hyperparameters, and differ only in whether the five categorical columns are one-hot encoded and handed to the model. The size of that difference is what Parts B and C are about.
Leave C=10 and gamma=0.1 alone in this part — tuning them is Part C’s job. Fit on the
training rows, score on the test rows.
A1. The number to beat (10 pts)#
Exercise 1
A classifier that ignores every column and always answers with the most common family still scores something, and every model below has to beat it.
Using y_test only, compute the fraction of test rows belonging to the largest of the
four families.
Assign it to baseline_accuracy.
# YOUR CODE HERE
baseline_accuracy = ...
check("q1", baseline_accuracy)
A2. Numeric columns only (10 pts)#
Exercise 2
Build the pipeline with use_categorical=False, so the model sees only the 16 numeric
fabrication and operating columns. Fit it on the training rows and score it on the test rows.
Assign the test accuracy to acc_numeric.
# YOUR CODE HERE
acc_numeric = ...
check("q2", acc_numeric)
A3. Adding the categorical columns (10 pts)#
Exercise 3
Now build the same pipeline with use_categorical=True, so the five categorical columns are
one-hot encoded and appended to the standardized numeric ones. Nothing else changes — same
split, same kernel, same C and gamma.
Fit, score on the test rows, and assign the test accuracy to acc_onehot.
Print the feature count both ways while you are here. Five columns became 20.
# YOUR CODE HERE
acc_onehot = ...
check("q3", acc_onehot)
Part A summary#
Features |
Test accuracy |
|
|---|---|---|
Majority family |
0 |
A1 |
Numeric only |
16 |
A2 |
Numeric + one-hot |
36 |
A3 |
Part B — Visualization (35 pts)#
In Part A, one-hot encoding gained more than every numeric column put together. That result is unspecific: five columns went in at once, so nothing yet says which of them carries the information, or which families it separates.
This part pins the effect down in two directions. Per-class metrics say which families the
encoding rescued — overall accuracy hides that one family was already being classified well
while another was barely being found at all. An ablation says which column does the work.
Keep two groups of columns apart as you write: the material categoricals describe what the
membrane is made of and how it is mounted (support_material, configuration), while the
protocol categoricals describe how the permeation test was run and reported (sweep_gas,
pore_size_type, provided_data_type). Which group wins is the thing to report.
Exercise 4
1. Compare the two models class by class. Produce a grouped bar chart of per-class
recall for the numeric-only and the one-hot model side by side, four families on the
\(x\)-axis. classification_report(..., output_dict=True) or
precision_recall_fscore_support will give you the numbers. Print the full classification
report for both models as well.
2. Show both confusion matrices. One figure, two panels, same family order on both axes, counts annotated in the cells. Use the same color scale in both panels so they can be read against each other.
3. Ablate the categorical columns. For each of the five, fit the pipeline with that
column as the only categorical (make_pipeline(True, categorical=[col])) and record the
test accuracy. Plot the five as a bar chart against the numeric-only accuracy as a reference
line.
4. Look at the winner’s contents. Take the categorical column that gained the most in
task 3 and cross-tabulate it against material_type over all 1,627 rows
(pd.crosstab). Show it as an annotated heatmap of row-normalized shares, so each row
reads as \(P(\text{family} \mid \text{level})\).
Then answer, in one or two sentences each:
(a) Which family gained the most recall from one-hot encoding, and which family was already being classified well without it?
(b) Which single categorical column gained the most, and is it a material column or a protocol column? Name which one and what that implies about what the model is reading.
(c) Pick one row of your task-4 heatmap that is almost entirely one family, and say in physical terms why that experimental choice goes with that material family.
Label your axes. Annotate the confusion-matrix cells with counts.
# YOUR CODE HERE
Part C — Open Ended (35 pts)#
Everything so far has used C=10 and gamma=0.1, values handed to you with no
justification, and a split by paper presented as simply the right thing to do. Part C tests
both choices. The second one turns out to matter far more than the first.
The chapter’s opening section gives the correct procedure: split first, tune inside the training set, touch the test set once. That protocol has a hole in it when rows are not independent. You can follow every step and still leak, because the leak is in the split itself rather than in the tuning. With 1,672 rows drawn from 42 papers, this database is exactly that case. Measure the effect, then say what the database can honestly support.
Exercise 5
1. Tune the kernel properly. Run GridSearchCV over \(C \in \{0.1, 1, 10, 100, 1000\}\)
and \(\gamma \in \{10^{-4}, 10^{-3}, 10^{-2}, 10^{-1}, 1\}\) for the one-hot pipeline, using
GroupKFold(n_splits=3) on the training rows only and passing groups=groups_train to
fit. Report the best \(C\) and \(\gamma\), the cross-validated score, and the test accuracy.
Compare against the untuned A3 result and say how much tuning was worth.
2. Measure the leak. Refit the same one-hot pipeline under a random row-wise split
(train_test_split(..., test_size=0.3, random_state=0, stratify=y)) and report its test
accuracy next to the grouped one. Report each split’s own majority-class baseline too — they
differ, so the raw accuracies are not directly comparable without them.
3. Test whether the leaky-looking column earns its place. support_material looks like it
should be a label in disguise: several of its levels contain exactly one family. Refit the
full one-hot pipeline without it and report the test accuracy. The result is not what the
crosstab predicts — explain the discrepancy in one or two sentences.
4. Say what the database supports. In two or three sentences: state whether this database can be used to train a model that predicts membrane behavior for a new research group’s membrane, and cite the two numbers from task 2 that decide it.
There is more than one defensible position in task 4. You are graded on whether the numbers you produced support the one you take.
# YOUR CODE HERE
Summary#
Part A ran one controlled experiment: the majority-family baseline, an RBF-kernel SVC on 16 numeric columns, and the same model with five categorical columns one-hot encoded into 20 more. The encoding was worth more than every numeric column combined.
Part B localized that gain — which families it rescued, and which single column carried it. The answer was a column describing the measurement protocol, not the material.
Part C tuned the kernel, found it barely moved the result, and then measured what the split by paper had been protecting against all along.
Additional Reading#
The database: github.com/tenoriolms/database_membranes (Apache-2.0), and L. M. S. Tenorio et al., “Construction and analysis of a detailed new database for gas separation membranes”, Observatório de la Economía Latinoamericana (2024).
scikit-learn User Guide: Cross-validation for grouped data, SVM, GridSearchCV, Encoding categorical features.
L. M. Robeson, “The upper bound revisited”, J. Membr. Sci. 320, 390 (2008). doi:10.1016/j.memsci.2008.04.030 — the permeability/selectivity trade-off that organizes this field, and the reason the permeability columns were withheld from this file.