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.
@@ -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
+ ]