Samuel-Collins-CV-Benchmarking 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- samuel_collins_cv_benchmarking/__init__.py +10 -0
- samuel_collins_cv_benchmarking/_config.py +19 -0
- samuel_collins_cv_benchmarking/benchmark.py +400 -0
- samuel_collins_cv_benchmarking/classical_models.py +125 -0
- samuel_collins_cv_benchmarking/data_loader.py +847 -0
- samuel_collins_cv_benchmarking/evaluation.py +276 -0
- samuel_collins_cv_benchmarking/neural_models.py +324 -0
- samuel_collins_cv_benchmarking/preprocessing.py +259 -0
- samuel_collins_cv_benchmarking/visualization.py +388 -0
- samuel_collins_cv_benchmarking-1.0.0.dist-info/METADATA +267 -0
- samuel_collins_cv_benchmarking-1.0.0.dist-info/RECORD +14 -0
- samuel_collins_cv_benchmarking-1.0.0.dist-info/WHEEL +5 -0
- samuel_collins_cv_benchmarking-1.0.0.dist-info/licenses/LICENSE +21 -0
- samuel_collins_cv_benchmarking-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Samuel Collins CV Benchmarking.
|
|
2
|
+
|
|
3
|
+
Compare classical machine-learning and neural-network image classifiers
|
|
4
|
+
through a single public function.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ._config import VERSION as __version__
|
|
8
|
+
from .benchmark import benchmark_image_classification
|
|
9
|
+
|
|
10
|
+
__all__ = ["benchmark_image_classification", "__version__"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Internal constants.
|
|
2
|
+
|
|
3
|
+
The assignment requires that users of the package configure nothing beyond the
|
|
4
|
+
four public parameters, so image size, seed and split live here rather than in
|
|
5
|
+
the public signature.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
DISTRIBUTION_NAME = "Samuel_Collins_CV_Benchmarking"
|
|
9
|
+
# Kept here rather than in __init__ so benchmark.py can read it without
|
|
10
|
+
# importing the package root, which would be circular.
|
|
11
|
+
VERSION = "1.0.0"
|
|
12
|
+
|
|
13
|
+
IMAGE_SIZE = (64, 64)
|
|
14
|
+
RANDOM_SEED = 42
|
|
15
|
+
TEST_SIZE = 0.20
|
|
16
|
+
RESULTS_DIR = "benchmark_results"
|
|
17
|
+
SUPPORTED_EXTENSIONS = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff")
|
|
18
|
+
DATASET_TYPES = ("folder", "csv", "json", "array")
|
|
19
|
+
COLOR_MODES = ("grayscale", "rgb")
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Orchestration for the public benchmarking entry point.
|
|
2
|
+
|
|
3
|
+
Also home to the stratified split, because section 10 names no module for it
|
|
4
|
+
and the fairness rule it enforces belongs with the orchestrator.
|
|
5
|
+
|
|
6
|
+
The important idea is that the split produces *indices*, not data. Section 4.3
|
|
7
|
+
requires two views of the same images - flattened vectors for the classical
|
|
8
|
+
models, tensors for the CNN - and section 7 requires every model to see
|
|
9
|
+
exactly the same training and testing samples. Splitting the two
|
|
10
|
+
representations separately would satisfy neither: two calls to
|
|
11
|
+
``train_test_split`` could diverge, and a divergence would be invisible in the
|
|
12
|
+
results. Splitting one index array once and slicing both views with it makes
|
|
13
|
+
that failure impossible rather than merely unlikely.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from ._config import (
|
|
23
|
+
COLOR_MODES,
|
|
24
|
+
DATASET_TYPES,
|
|
25
|
+
DISTRIBUTION_NAME,
|
|
26
|
+
IMAGE_SIZE,
|
|
27
|
+
RANDOM_SEED,
|
|
28
|
+
RESULTS_DIR,
|
|
29
|
+
TEST_SIZE,
|
|
30
|
+
VERSION,
|
|
31
|
+
)
|
|
32
|
+
from .preprocessing import PreparedDataset, preprocess
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Split:
|
|
37
|
+
"""One stratified train/test division, reused by every model.
|
|
38
|
+
|
|
39
|
+
Holds positions into a :class:`PreparedDataset` rather than copies of it,
|
|
40
|
+
so the training and testing views of both representations are guaranteed
|
|
41
|
+
to come from the same division.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
dataset: PreparedDataset
|
|
45
|
+
train_index: np.ndarray = field(default_factory=lambda: np.empty(0, np.int64))
|
|
46
|
+
test_index: np.ndarray = field(default_factory=lambda: np.empty(0, np.int64))
|
|
47
|
+
random_seed: int = RANDOM_SEED
|
|
48
|
+
test_size: float = TEST_SIZE
|
|
49
|
+
|
|
50
|
+
# Flattened vectors, for Logistic Regression, Decision Tree, Random
|
|
51
|
+
# Forest, SVM and the fully connected network.
|
|
52
|
+
@property
|
|
53
|
+
def features_train(self) -> np.ndarray:
|
|
54
|
+
return self.dataset.features[self.train_index]
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def features_test(self) -> np.ndarray:
|
|
58
|
+
return self.dataset.features[self.test_index]
|
|
59
|
+
|
|
60
|
+
# Image tensors, for the CNN.
|
|
61
|
+
@property
|
|
62
|
+
def images_train(self) -> np.ndarray:
|
|
63
|
+
return self.dataset.images[self.train_index]
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def images_test(self) -> np.ndarray:
|
|
67
|
+
return self.dataset.images[self.test_index]
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def labels_train(self) -> np.ndarray:
|
|
71
|
+
return self.dataset.labels[self.train_index]
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def labels_test(self) -> np.ndarray:
|
|
75
|
+
return self.dataset.labels[self.test_index]
|
|
76
|
+
|
|
77
|
+
def class_distribution(self, labels: np.ndarray) -> dict:
|
|
78
|
+
"""Counts per class name for any label array from this dataset."""
|
|
79
|
+
counts = {name: 0 for name in self.dataset.class_names}
|
|
80
|
+
for encoded in labels:
|
|
81
|
+
counts[self.dataset.class_names[encoded]] += 1
|
|
82
|
+
return counts
|
|
83
|
+
|
|
84
|
+
def distributions(self) -> dict:
|
|
85
|
+
"""The three distributions section 5 requires in the returned result."""
|
|
86
|
+
return {
|
|
87
|
+
"full": self.class_distribution(self.dataset.labels),
|
|
88
|
+
"training": self.class_distribution(self.labels_train),
|
|
89
|
+
"testing": self.class_distribution(self.labels_test),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
def summary(self) -> dict:
|
|
93
|
+
"""Split information for the returned dictionary and the run config."""
|
|
94
|
+
return {
|
|
95
|
+
"training_samples": int(len(self.train_index)),
|
|
96
|
+
"testing_samples": int(len(self.test_index)),
|
|
97
|
+
"test_size": self.test_size,
|
|
98
|
+
"random_seed": self.random_seed,
|
|
99
|
+
"stratified": True,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def make_split(
|
|
104
|
+
dataset: PreparedDataset,
|
|
105
|
+
test_size: float = TEST_SIZE,
|
|
106
|
+
random_seed: int = RANDOM_SEED,
|
|
107
|
+
) -> Split:
|
|
108
|
+
"""Divide a prepared dataset once, stratified by label.
|
|
109
|
+
|
|
110
|
+
Parameters
|
|
111
|
+
----------
|
|
112
|
+
dataset
|
|
113
|
+
Output of :func:`~.preprocessing.preprocess`.
|
|
114
|
+
test_size
|
|
115
|
+
Fraction held out for testing. Section 5 fixes this at 0.20.
|
|
116
|
+
random_seed
|
|
117
|
+
Section 5 fixes this at 42.
|
|
118
|
+
|
|
119
|
+
Returns
|
|
120
|
+
-------
|
|
121
|
+
Split
|
|
122
|
+
Index arrays for the training and testing halves.
|
|
123
|
+
|
|
124
|
+
Raises
|
|
125
|
+
------
|
|
126
|
+
ValueError
|
|
127
|
+
A class has too few samples for a stratified split, or the test
|
|
128
|
+
fraction is too small to give every class a testing sample.
|
|
129
|
+
"""
|
|
130
|
+
from sklearn.model_selection import train_test_split
|
|
131
|
+
|
|
132
|
+
total = len(dataset)
|
|
133
|
+
class_count = len(dataset.class_names)
|
|
134
|
+
counts = dataset.count_by_class()
|
|
135
|
+
|
|
136
|
+
# Section 4.1 asks for a clear error when a stratified split cannot be
|
|
137
|
+
# created. scikit-learn's own messages describe the constraint without
|
|
138
|
+
# naming the class that violates it, which is the one thing the caller
|
|
139
|
+
# needs in order to fix their data.
|
|
140
|
+
thin = {name: count for name, count in counts.items() if count < 2}
|
|
141
|
+
if thin:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
f"Cannot build a stratified split: {thin} - every class needs at "
|
|
144
|
+
"least 2 images so that it can appear in both the training and "
|
|
145
|
+
"testing sets."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
expected_test = int(np.floor(total * test_size))
|
|
149
|
+
if expected_test < class_count:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"Cannot build a stratified split: a {test_size:.0%} test fraction of "
|
|
152
|
+
f"{total} image(s) is {expected_test} sample(s), fewer than the "
|
|
153
|
+
f"{class_count} classes. Use more images per class."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Split positions, not pixels. Both representations are then sliced with
|
|
157
|
+
# the same indices, so they cannot disagree about who is in which half.
|
|
158
|
+
positions = np.arange(total)
|
|
159
|
+
train_index, test_index = train_test_split(
|
|
160
|
+
positions,
|
|
161
|
+
test_size=test_size,
|
|
162
|
+
stratify=dataset.labels,
|
|
163
|
+
random_state=random_seed,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Sorted so the indices are stable to read, compare and serialize into
|
|
167
|
+
# run_configuration.json. Membership is what stratification fixes; order
|
|
168
|
+
# within each half carries no meaning.
|
|
169
|
+
return Split(
|
|
170
|
+
dataset=dataset,
|
|
171
|
+
train_index=np.sort(train_index),
|
|
172
|
+
test_index=np.sort(test_index),
|
|
173
|
+
random_seed=random_seed,
|
|
174
|
+
test_size=test_size,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _load(dataset, dataset_type: str, target_labels):
|
|
179
|
+
"""Hand the input to the loader that understands it.
|
|
180
|
+
|
|
181
|
+
``target_labels`` means something different to each of these - a list of
|
|
182
|
+
class-folder names, a column name, a field name, or a label vector - so
|
|
183
|
+
each loader is responsible for saying so when it is given the wrong shape.
|
|
184
|
+
"""
|
|
185
|
+
from . import data_loader
|
|
186
|
+
|
|
187
|
+
if dataset_type == "folder":
|
|
188
|
+
return data_loader.load_folder(dataset, target_labels)
|
|
189
|
+
if dataset_type == "csv":
|
|
190
|
+
return data_loader.load_csv(dataset, target_labels)
|
|
191
|
+
if dataset_type == "json":
|
|
192
|
+
return data_loader.load_json(dataset, target_labels)
|
|
193
|
+
return data_loader.load_array(dataset, target_labels)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _jsonable(value):
|
|
197
|
+
"""Convert numpy scalars and arrays into something json can write."""
|
|
198
|
+
if isinstance(value, np.ndarray):
|
|
199
|
+
return value.tolist()
|
|
200
|
+
if isinstance(value, (np.integer,)):
|
|
201
|
+
return int(value)
|
|
202
|
+
if isinstance(value, (np.floating,)):
|
|
203
|
+
return float(value)
|
|
204
|
+
if isinstance(value, dict):
|
|
205
|
+
return {str(k): _jsonable(v) for k, v in value.items()}
|
|
206
|
+
if isinstance(value, (list, tuple)):
|
|
207
|
+
return [_jsonable(v) for v in value]
|
|
208
|
+
return value
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _write_artifacts(results, split, dataset_type, color_mode, output_dir: Path) -> dict:
|
|
212
|
+
"""Write the result directory section 9 requires.
|
|
213
|
+
|
|
214
|
+
Everything here is written from the results already computed - nothing is
|
|
215
|
+
recalculated - so the files and the returned dictionary cannot disagree.
|
|
216
|
+
"""
|
|
217
|
+
from . import visualization
|
|
218
|
+
from .evaluation import (
|
|
219
|
+
classification_report_frame,
|
|
220
|
+
prediction_examples,
|
|
221
|
+
summary_frame,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
output_dir = Path(output_dir)
|
|
225
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
(output_dir / "classification_reports").mkdir(exist_ok=True)
|
|
227
|
+
|
|
228
|
+
summary = summary_frame(results)
|
|
229
|
+
summary.to_csv(output_dir / "benchmark_summary.csv", index=False)
|
|
230
|
+
|
|
231
|
+
metrics = {
|
|
232
|
+
result.key: {
|
|
233
|
+
"name": result.name,
|
|
234
|
+
"succeeded": result.succeeded,
|
|
235
|
+
"error": result.error,
|
|
236
|
+
**_jsonable(result.metrics()),
|
|
237
|
+
"confusion_matrix": _jsonable(result.confusion_matrix),
|
|
238
|
+
"classification_report": _jsonable(result.classification_report),
|
|
239
|
+
"training_history": _jsonable(result.history),
|
|
240
|
+
# Section 11 asks for examples of correct and incorrect
|
|
241
|
+
# predictions, and the report reads files rather than importing
|
|
242
|
+
# the pipeline, so they have to be written rather than only
|
|
243
|
+
# returned.
|
|
244
|
+
"prediction_examples": _jsonable(prediction_examples(result, split)),
|
|
245
|
+
}
|
|
246
|
+
for result in results
|
|
247
|
+
}
|
|
248
|
+
(output_dir / "benchmark_metrics.json").write_text(
|
|
249
|
+
json.dumps(metrics, indent=2), encoding="utf-8")
|
|
250
|
+
|
|
251
|
+
# Section 9: the configuration file records image size, color mode, seed,
|
|
252
|
+
# split, model parameters and package version - everything a reader needs
|
|
253
|
+
# to reproduce the run without reading the source.
|
|
254
|
+
configuration = {
|
|
255
|
+
"package": {"name": DISTRIBUTION_NAME, "version": VERSION},
|
|
256
|
+
"dataset_type": dataset_type,
|
|
257
|
+
"color_mode": color_mode,
|
|
258
|
+
"image_size": list(IMAGE_SIZE),
|
|
259
|
+
"random_seed": split.random_seed,
|
|
260
|
+
"split": _jsonable(split.summary()),
|
|
261
|
+
"class_names": list(split.dataset.class_names),
|
|
262
|
+
"models": {result.key: _jsonable(result.parameters) for result in results},
|
|
263
|
+
}
|
|
264
|
+
(output_dir / "run_configuration.json").write_text(
|
|
265
|
+
json.dumps(configuration, indent=2), encoding="utf-8")
|
|
266
|
+
|
|
267
|
+
for result in results:
|
|
268
|
+
if result.succeeded:
|
|
269
|
+
classification_report_frame(result).to_csv(
|
|
270
|
+
output_dir / "classification_reports" / f"{result.key}.csv",
|
|
271
|
+
index=False)
|
|
272
|
+
|
|
273
|
+
visualization.save_all(results, split, output_dir)
|
|
274
|
+
return metrics
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def benchmark_image_classification(
|
|
278
|
+
dataset,
|
|
279
|
+
dataset_type: str,
|
|
280
|
+
target_labels,
|
|
281
|
+
color_mode: str,
|
|
282
|
+
) -> dict:
|
|
283
|
+
"""Load an image dataset, train all required classifiers,
|
|
284
|
+
and return a complete benchmark comparison.
|
|
285
|
+
|
|
286
|
+
Runs the whole pipeline: read the dataset in whichever of the four
|
|
287
|
+
organizations it arrives in, standardize every image to 64x64 in the
|
|
288
|
+
requested color mode, build one stratified 80/20 split at seed 42, train
|
|
289
|
+
and score all six models on that same split, write the result directory,
|
|
290
|
+
and return the comparison.
|
|
291
|
+
|
|
292
|
+
Parameters
|
|
293
|
+
----------
|
|
294
|
+
dataset
|
|
295
|
+
Dataset root directory, CSV/JSON/JSONL manifest path, Pandas
|
|
296
|
+
DataFrame, or NumPy image array.
|
|
297
|
+
dataset_type
|
|
298
|
+
One of "folder", "csv", "json", or "array".
|
|
299
|
+
target_labels
|
|
300
|
+
Class-folder names, manifest label-field name, DataFrame label
|
|
301
|
+
column, or a label vector.
|
|
302
|
+
color_mode
|
|
303
|
+
Either "grayscale" for one channel or "rgb" for three channels.
|
|
304
|
+
|
|
305
|
+
Returns
|
|
306
|
+
-------
|
|
307
|
+
dict
|
|
308
|
+
The benchmark table, best model, dataset and split information,
|
|
309
|
+
per-model results, confusion matrices, class-level reports and
|
|
310
|
+
warnings. Output files are written to ``benchmark_results/``.
|
|
311
|
+
"""
|
|
312
|
+
from .classical_models import classical_model_specs
|
|
313
|
+
from .evaluation import (
|
|
314
|
+
best_model,
|
|
315
|
+
evaluate_all,
|
|
316
|
+
prediction_examples,
|
|
317
|
+
summary_frame,
|
|
318
|
+
)
|
|
319
|
+
from .neural_models import neural_model_specs
|
|
320
|
+
|
|
321
|
+
if dataset_type not in DATASET_TYPES:
|
|
322
|
+
raise ValueError(
|
|
323
|
+
f"dataset_type must be one of {DATASET_TYPES}, got {dataset_type!r}"
|
|
324
|
+
)
|
|
325
|
+
if color_mode not in COLOR_MODES:
|
|
326
|
+
raise ValueError(
|
|
327
|
+
f"color_mode must be one of {COLOR_MODES}, got {color_mode!r}"
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
loaded = _load(dataset, dataset_type, target_labels)
|
|
331
|
+
print(f"loaded {len(loaded)} sample(s) across {len(loaded.class_names)} class(es)")
|
|
332
|
+
|
|
333
|
+
prepared = preprocess(loaded, color_mode)
|
|
334
|
+
if prepared.skipped:
|
|
335
|
+
print(f"skipped {len(prepared.skipped)} undecodable image(s)")
|
|
336
|
+
print(f"standardized to {prepared.images.shape[1:]} in {color_mode}")
|
|
337
|
+
|
|
338
|
+
split = make_split(prepared)
|
|
339
|
+
print(f"split {len(split.train_index)} training / {len(split.test_index)} testing")
|
|
340
|
+
|
|
341
|
+
specs = classical_model_specs() + neural_model_specs()
|
|
342
|
+
|
|
343
|
+
# Printed as each model finishes: an RBF SVM on 12,288 features takes
|
|
344
|
+
# minutes, and a run that prints nothing for that long looks hung.
|
|
345
|
+
def announce(result):
|
|
346
|
+
if result.succeeded:
|
|
347
|
+
print(f" {result.name:20} macro F1 {result.macro_f1:.4f} "
|
|
348
|
+
f"fit {result.training_time_seconds:.2f}s")
|
|
349
|
+
else:
|
|
350
|
+
print(f" {result.name:20} FAILED - {result.error}")
|
|
351
|
+
|
|
352
|
+
results = evaluate_all(specs, split, on_progress=announce)
|
|
353
|
+
|
|
354
|
+
output_dir = Path(RESULTS_DIR)
|
|
355
|
+
_write_artifacts(results, split, dataset_type, color_mode, output_dir)
|
|
356
|
+
print(f"wrote results to {output_dir.resolve()}")
|
|
357
|
+
|
|
358
|
+
channels = prepared.images.shape[3]
|
|
359
|
+
return {
|
|
360
|
+
"package_information": {"name": DISTRIBUTION_NAME, "version": VERSION},
|
|
361
|
+
"summary": summary_frame(results),
|
|
362
|
+
"best_model": best_model(results),
|
|
363
|
+
"dataset_information": {
|
|
364
|
+
"dataset_type": dataset_type,
|
|
365
|
+
"number_of_images": len(prepared),
|
|
366
|
+
"number_of_classes": len(prepared.class_names),
|
|
367
|
+
"class_names": list(prepared.class_names),
|
|
368
|
+
"color_mode": color_mode,
|
|
369
|
+
"image_shape": [*IMAGE_SIZE, channels],
|
|
370
|
+
"class_distribution": split.distributions()["full"],
|
|
371
|
+
"skipped_images": list(prepared.skipped),
|
|
372
|
+
},
|
|
373
|
+
"split_information": {
|
|
374
|
+
**split.summary(),
|
|
375
|
+
"training_distribution": split.distributions()["training"],
|
|
376
|
+
"testing_distribution": split.distributions()["testing"],
|
|
377
|
+
},
|
|
378
|
+
"model_results": {
|
|
379
|
+
result.key: {
|
|
380
|
+
"name": result.name,
|
|
381
|
+
"succeeded": result.succeeded,
|
|
382
|
+
"error": result.error,
|
|
383
|
+
**result.metrics(),
|
|
384
|
+
"parameters": result.parameters,
|
|
385
|
+
"training_history": result.history,
|
|
386
|
+
"prediction_examples": prediction_examples(result, split),
|
|
387
|
+
}
|
|
388
|
+
for result in results
|
|
389
|
+
},
|
|
390
|
+
"confusion_matrices": {
|
|
391
|
+
result.key: result.confusion_matrix
|
|
392
|
+
for result in results if result.succeeded
|
|
393
|
+
},
|
|
394
|
+
"classification_reports": {
|
|
395
|
+
result.key: result.classification_report
|
|
396
|
+
for result in results if result.succeeded
|
|
397
|
+
},
|
|
398
|
+
"warnings": list(prepared.warnings),
|
|
399
|
+
"output_directory": str(output_dir.resolve()),
|
|
400
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Logistic Regression, Decision Tree, Random Forest and SVM.
|
|
2
|
+
|
|
3
|
+
This module only *describes* the models. Fitting, timing and scoring belong to
|
|
4
|
+
evaluation, so that every model - classical or neural - is measured by the
|
|
5
|
+
same code and the comparison stays fair.
|
|
6
|
+
|
|
7
|
+
Scaling is the part worth reading carefully. Section 6.1 requires Logistic
|
|
8
|
+
Regression, SVM and the fully connected network to use a scaler fitted on the
|
|
9
|
+
training data only. The tempting shortcut is::
|
|
10
|
+
|
|
11
|
+
scaled = StandardScaler().fit_transform(features) # WRONG
|
|
12
|
+
train, test = split(scaled)
|
|
13
|
+
|
|
14
|
+
That fits the scaler on every image, so the mean and variance of the test set
|
|
15
|
+
inform the transformation applied to the training set. Nothing errors, the
|
|
16
|
+
accuracy simply comes out slightly too high, and section 7 prohibits it.
|
|
17
|
+
|
|
18
|
+
Wrapping the scaler and the classifier in a ``Pipeline`` removes the choice:
|
|
19
|
+
``pipeline.fit(train)`` fits the scaler on the training rows only, and
|
|
20
|
+
``pipeline.predict(test)`` reuses those training statistics. The mistake stops
|
|
21
|
+
being something to remember not to make.
|
|
22
|
+
|
|
23
|
+
Decision Tree and Random Forest are deliberately left unscaled. They split on
|
|
24
|
+
thresholds within a single feature, so a monotonic rescaling of that feature
|
|
25
|
+
changes nothing about the tree; adding a scaler would cost time and imply a
|
|
26
|
+
dependence that is not there.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from typing import Callable
|
|
31
|
+
|
|
32
|
+
from sklearn.ensemble import RandomForestClassifier
|
|
33
|
+
from sklearn.linear_model import LogisticRegression
|
|
34
|
+
from sklearn.pipeline import Pipeline
|
|
35
|
+
from sklearn.preprocessing import StandardScaler
|
|
36
|
+
from sklearn.svm import SVC
|
|
37
|
+
from sklearn.tree import DecisionTreeClassifier
|
|
38
|
+
|
|
39
|
+
from ._config import RANDOM_SEED
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ModelSpec:
|
|
44
|
+
"""A model the benchmark will train, with the metadata around it.
|
|
45
|
+
|
|
46
|
+
``key`` is the file-safe form used for the per-model output files section
|
|
47
|
+
9 requires, so that ``name`` can stay readable in the summary table while
|
|
48
|
+
``key`` names ``confusion_matrices/logistic_regression.png``.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
key: str
|
|
52
|
+
name: str
|
|
53
|
+
build: Callable
|
|
54
|
+
scaled: bool
|
|
55
|
+
parameters: dict = field(default_factory=dict)
|
|
56
|
+
kind: str = "classical"
|
|
57
|
+
|
|
58
|
+
def __call__(self):
|
|
59
|
+
"""Construct a fresh, unfitted estimator."""
|
|
60
|
+
return self.build()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _scaled(estimator) -> Pipeline:
|
|
64
|
+
"""Attach a scaler that can only ever see training rows."""
|
|
65
|
+
return Pipeline([("scaler", StandardScaler()), ("classifier", estimator)])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def logistic_regression_spec() -> ModelSpec:
|
|
69
|
+
parameters = {"max_iter": 1000, "random_state": RANDOM_SEED}
|
|
70
|
+
return ModelSpec(
|
|
71
|
+
key="logistic_regression",
|
|
72
|
+
name="Logistic Regression",
|
|
73
|
+
build=lambda: _scaled(LogisticRegression(**parameters)),
|
|
74
|
+
scaled=True,
|
|
75
|
+
parameters=parameters,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def decision_tree_spec() -> ModelSpec:
|
|
80
|
+
parameters = {"random_state": RANDOM_SEED}
|
|
81
|
+
return ModelSpec(
|
|
82
|
+
key="decision_tree",
|
|
83
|
+
name="Decision Tree",
|
|
84
|
+
build=lambda: DecisionTreeClassifier(**parameters),
|
|
85
|
+
scaled=False,
|
|
86
|
+
parameters=parameters,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def random_forest_spec() -> ModelSpec:
|
|
91
|
+
# n_jobs=-1 uses every core. It changes how the work is distributed, not
|
|
92
|
+
# what the trees are, so a fixed random_state still reproduces exactly.
|
|
93
|
+
parameters = {"n_estimators": 200, "random_state": RANDOM_SEED, "n_jobs": -1}
|
|
94
|
+
return ModelSpec(
|
|
95
|
+
key="random_forest",
|
|
96
|
+
name="Random Forest",
|
|
97
|
+
build=lambda: RandomForestClassifier(**parameters),
|
|
98
|
+
scaled=False,
|
|
99
|
+
parameters=parameters,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def svm_spec() -> ModelSpec:
|
|
104
|
+
# An RBF SVM on 12,288 features is the slow one: its cost grows with the
|
|
105
|
+
# square of the sample count. On a 5,000-image RGB run expect minutes.
|
|
106
|
+
# That is a result worth reporting, which is why section 8 asks for
|
|
107
|
+
# training time, not a problem to design away.
|
|
108
|
+
parameters = {"kernel": "rbf", "random_state": RANDOM_SEED}
|
|
109
|
+
return ModelSpec(
|
|
110
|
+
key="svm",
|
|
111
|
+
name="SVM",
|
|
112
|
+
build=lambda: _scaled(SVC(**parameters)),
|
|
113
|
+
scaled=True,
|
|
114
|
+
parameters=parameters,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def classical_model_specs() -> list:
|
|
119
|
+
"""The four classical models, in the order the summary table lists them."""
|
|
120
|
+
return [
|
|
121
|
+
logistic_regression_spec(),
|
|
122
|
+
decision_tree_spec(),
|
|
123
|
+
random_forest_spec(),
|
|
124
|
+
svm_spec(),
|
|
125
|
+
]
|