Justin-Williams-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,21 @@
1
+ """Justin_Williams_CV_Benchmarking.
2
+
3
+ Benchmark six classical and neural image classifiers on any labelled image
4
+ dataset with a single function call::
5
+
6
+ from justin_williams_cv_benchmarking import benchmark_image_classification
7
+
8
+ results = benchmark_image_classification(
9
+ dataset="./animals",
10
+ dataset_type="folder",
11
+ target_labels=["cat", "dog", "horse"],
12
+ color_mode="rgb",
13
+ )
14
+ """
15
+
16
+ __version__ = "1.0.0"
17
+
18
+ from .benchmark import benchmark_image_classification # noqa: E402
19
+ from .data_loader import DatasetError # noqa: E402
20
+
21
+ __all__ = ["benchmark_image_classification", "DatasetError", "__version__"]
@@ -0,0 +1,416 @@
1
+ """The public entry point: :func:`benchmark_image_classification`.
2
+
3
+ One call loads any of the four supported dataset organisations, standardises
4
+ it, creates a single stratified split, trains all six required models on that
5
+ one split, evaluates them with identical metrics, writes every required
6
+ artefact to ``benchmark_results/`` and returns the whole comparison.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import platform
14
+ import random
15
+ import time
16
+ from typing import Any, Dict
17
+
18
+ import numpy as np
19
+
20
+ from . import __version__
21
+ from .classical_models import build_classical_models, describe
22
+ from .config import (
23
+ CLASSIFICATION_REPORT_DIR,
24
+ CNN_BATCH_SIZE,
25
+ CNN_EARLY_STOPPING_PATIENCE,
26
+ CNN_MAX_EPOCHS,
27
+ CONFUSION_MATRIX_DIR,
28
+ IMAGE_SIZE,
29
+ MODEL_FILE_STEMS,
30
+ MODEL_ORDER,
31
+ OUTPUT_DIR,
32
+ PACKAGE_NAME,
33
+ RANDOM_SEED,
34
+ TEST_SIZE,
35
+ )
36
+ from .data_loader import DatasetError, load_dataset
37
+ from .evaluation import (
38
+ build_summary,
39
+ best_model_name,
40
+ classification_report_frame,
41
+ compute_metrics,
42
+ confusion_matrix_array,
43
+ prediction_examples,
44
+ )
45
+ from .neural_models import build_cnn_model, build_mlp_model
46
+ from .preprocessing import distribution, prepare_dataset, stratified_split
47
+ from .visualization import (
48
+ ensure_directory,
49
+ plot_class_distribution,
50
+ plot_confusion_matrix,
51
+ plot_model_comparison,
52
+ plot_prediction_examples,
53
+ )
54
+
55
+ __all__ = ["benchmark_image_classification", "DatasetError"]
56
+
57
+ # Models fed the flattened feature vectors rather than the image tensor.
58
+ _FLAT_INPUT_MODELS = ("Logistic Regression", "Decision Tree", "Random Forest", "SVM", "Neural Network")
59
+
60
+
61
+ def _seed_everything(seed: int = RANDOM_SEED) -> None:
62
+ random.seed(seed)
63
+ np.random.seed(seed)
64
+ try:
65
+ import torch
66
+
67
+ torch.manual_seed(seed)
68
+ except Exception: # pragma: no cover - torch is a hard dependency in practice
69
+ pass
70
+
71
+
72
+ def _json_safe(value: Any) -> Any:
73
+ """Recursively convert NumPy scalars/arrays into JSON-serialisable values."""
74
+ if isinstance(value, dict):
75
+ return {str(key): _json_safe(item) for key, item in value.items()}
76
+ if isinstance(value, (list, tuple)):
77
+ return [_json_safe(item) for item in value]
78
+ if isinstance(value, np.ndarray):
79
+ return value.tolist()
80
+ if isinstance(value, (np.integer,)):
81
+ return int(value)
82
+ if isinstance(value, (np.floating,)):
83
+ number = float(value)
84
+ return None if np.isnan(number) else number
85
+ if isinstance(value, (np.bool_,)):
86
+ return bool(value)
87
+ if isinstance(value, float) and np.isnan(value):
88
+ return None
89
+ return value
90
+
91
+
92
+ def _build_models() -> Dict[str, object]:
93
+ """All six models, in reporting order."""
94
+ models = build_classical_models()
95
+ models["Neural Network"] = build_mlp_model()
96
+ models["Simple CNN"] = build_cnn_model()
97
+ return {name: models[name] for name in MODEL_ORDER}
98
+
99
+
100
+ def _write_json(path: str, payload: Any) -> None:
101
+ with open(path, "w", encoding="utf-8") as handle:
102
+ json.dump(_json_safe(payload), handle, indent=2, sort_keys=False)
103
+ handle.write("\n")
104
+
105
+
106
+ def benchmark_image_classification(
107
+ dataset,
108
+ dataset_type: str,
109
+ target_labels,
110
+ color_mode: str,
111
+ ) -> dict:
112
+ """Load an image dataset, train all required classifiers,
113
+ and return a complete benchmark comparison.
114
+
115
+ Parameters
116
+ ----------
117
+ dataset:
118
+ Dataset root directory, CSV/JSON/JSONL manifest path, Pandas DataFrame,
119
+ or NumPy image array.
120
+ dataset_type:
121
+ One of ``"folder"``, ``"csv"``, ``"json"`` or ``"array"``.
122
+ target_labels:
123
+ Class-folder names, manifest label-field name, DataFrame label column,
124
+ or a label vector.
125
+ color_mode:
126
+ ``"grayscale"`` for one channel or ``"rgb"`` for three.
127
+
128
+ Returns
129
+ -------
130
+ dict
131
+ Benchmark table, best model, dataset summary, split information,
132
+ per-model results, confusion matrices, class-level reports and
133
+ warnings. The same artefacts are written to ``benchmark_results/``.
134
+ """
135
+ _seed_everything()
136
+
137
+ # -- 1. load and validate ------------------------------------------------
138
+ loaded = load_dataset(dataset, dataset_type, target_labels)
139
+
140
+ # -- 2. standardise ------------------------------------------------------
141
+ prepared = prepare_dataset(loaded, color_mode)
142
+ warnings = list(prepared.warnings)
143
+ class_names = prepared.class_names
144
+ n_classes = len(class_names)
145
+
146
+ # -- 3. one stratified split, reused by every model ----------------------
147
+ train_index, test_index = stratified_split(prepared.y)
148
+ y_train = prepared.y[train_index]
149
+ y_test = prepared.y[test_index]
150
+
151
+ features_train = prepared.features[train_index]
152
+ features_test = prepared.features[test_index]
153
+ images_train = prepared.images[train_index]
154
+ images_test = prepared.images[test_index]
155
+
156
+ # -- 4. train and evaluate every model on that one split -----------------
157
+ model_results: Dict[str, dict] = {}
158
+ confusion_matrices: Dict[str, np.ndarray] = {}
159
+ classification_reports: Dict[str, Any] = {}
160
+
161
+ for name, model in _build_models().items():
162
+ uses_flat = name in _FLAT_INPUT_MODELS
163
+ x_train = features_train if uses_flat else images_train
164
+ x_test = features_test if uses_flat else images_test
165
+
166
+ record: Dict[str, Any] = {
167
+ "status": "success",
168
+ "error": None,
169
+ "input_representation": "flattened features" if uses_flat else "image tensor",
170
+ "training_samples": int(len(train_index)),
171
+ "testing_samples": int(len(test_index)),
172
+ }
173
+
174
+ try:
175
+ started = time.perf_counter()
176
+ model.fit(x_train, y_train)
177
+ record["training_time_seconds"] = float(time.perf_counter() - started)
178
+
179
+ started = time.perf_counter()
180
+ predictions = np.asarray(model.predict(x_test))
181
+ inference_seconds = float(time.perf_counter() - started)
182
+
183
+ if predictions.shape[0] != y_test.shape[0]:
184
+ raise RuntimeError(
185
+ "model returned %d predictions for %d test images"
186
+ % (predictions.shape[0], y_test.shape[0])
187
+ )
188
+
189
+ record["inference_time_seconds_total"] = inference_seconds
190
+ record["inference_time_ms_per_image"] = (
191
+ inference_seconds / max(len(y_test), 1) * 1000.0
192
+ )
193
+ record["metrics"] = compute_metrics(y_test, predictions, n_classes)
194
+ record["predictions"] = predictions
195
+ record["configuration"] = describe(model)
196
+
197
+ confusion_matrices[name] = confusion_matrix_array(y_test, predictions, n_classes)
198
+ classification_reports[name] = classification_report_frame(
199
+ y_test, predictions, class_names
200
+ )
201
+ record["prediction_examples"] = prediction_examples(
202
+ y_test, predictions, test_index, class_names
203
+ )
204
+ if name == "Simple CNN":
205
+ record["epochs_run"] = int(getattr(model, "epochs_run_", 0))
206
+ record["best_epoch"] = int(getattr(model, "best_epoch_", 0))
207
+ record["stopped_early"] = bool(getattr(model, "stopped_early_", False))
208
+
209
+ except Exception as exc: # noqa: BLE001 - a failed model is reported, not hidden
210
+ record["status"] = "failed"
211
+ record["error"] = "%s: %s" % (type(exc).__name__, exc)
212
+ record.setdefault("training_time_seconds", float("nan"))
213
+ record["inference_time_ms_per_image"] = float("nan")
214
+ record["metrics"] = {}
215
+ record["predictions"] = np.array([], dtype=np.int64)
216
+ record["configuration"] = describe(model)
217
+ warnings.append("Model %r failed and is reported without metrics: %s" % (name, record["error"]))
218
+
219
+ model_results[name] = record
220
+
221
+ # -- 5. rank -------------------------------------------------------------
222
+ summary = build_summary(model_results)
223
+ best_model = best_model_name(summary)
224
+ if not best_model:
225
+ warnings.append("Every model failed; there is no best model to report.")
226
+
227
+ # -- 6. distributions ----------------------------------------------------
228
+ split_information = {
229
+ "training_samples": int(len(train_index)),
230
+ "testing_samples": int(len(test_index)),
231
+ "test_size": TEST_SIZE,
232
+ "random_seed": RANDOM_SEED,
233
+ "stratified": True,
234
+ "shared_by_all_models": True,
235
+ "full_class_distribution": distribution(prepared.y, class_names),
236
+ "training_class_distribution": distribution(y_train, class_names),
237
+ "testing_class_distribution": distribution(y_test, class_names),
238
+ }
239
+
240
+ dataset_information = {
241
+ "dataset_type": loaded.dataset_type,
242
+ "number_of_images": int(prepared.images.shape[0]),
243
+ "number_of_classes": n_classes,
244
+ "class_names": list(class_names),
245
+ "color_mode": prepared.color_mode,
246
+ "image_shape": list(prepared.image_shape),
247
+ "flattened_features": int(prepared.features.shape[1]),
248
+ "skipped_samples": len(prepared.skipped),
249
+ "skipped_details": prepared.skipped,
250
+ }
251
+
252
+ # -- 7. write every required artefact ------------------------------------
253
+ output_directory = _write_outputs(
254
+ summary=summary,
255
+ model_results=model_results,
256
+ confusion_matrices=confusion_matrices,
257
+ classification_reports=classification_reports,
258
+ class_names=class_names,
259
+ dataset_information=dataset_information,
260
+ split_information=split_information,
261
+ best_model=best_model,
262
+ warnings=warnings,
263
+ images_test=images_test,
264
+ y_test=y_test,
265
+ )
266
+
267
+ return {
268
+ "package_information": {"name": PACKAGE_NAME, "version": __version__},
269
+ "summary": summary,
270
+ "best_model": best_model,
271
+ "dataset_information": dataset_information,
272
+ "split_information": split_information,
273
+ "model_results": model_results,
274
+ "confusion_matrices": confusion_matrices,
275
+ "classification_reports": classification_reports,
276
+ "warnings": warnings,
277
+ "output_directory": output_directory,
278
+ "class_names": list(class_names),
279
+ "test_indices": test_index,
280
+ "train_indices": train_index,
281
+ }
282
+
283
+
284
+ def _write_outputs(
285
+ summary,
286
+ model_results,
287
+ confusion_matrices,
288
+ classification_reports,
289
+ class_names,
290
+ dataset_information,
291
+ split_information,
292
+ best_model,
293
+ warnings,
294
+ images_test,
295
+ y_test,
296
+ ) -> str:
297
+ """Create ``benchmark_results/`` and populate it with every required file."""
298
+ import sklearn
299
+
300
+ root = ensure_directory(os.path.abspath(OUTPUT_DIR))
301
+ matrices_dir = ensure_directory(os.path.join(root, CONFUSION_MATRIX_DIR))
302
+ reports_dir = ensure_directory(os.path.join(root, CLASSIFICATION_REPORT_DIR))
303
+
304
+ summary.to_csv(os.path.join(root, "benchmark_summary.csv"), index=False)
305
+
306
+ metrics_payload = {
307
+ "package_information": {"name": PACKAGE_NAME, "version": __version__},
308
+ "best_model": best_model,
309
+ "dataset_information": dataset_information,
310
+ "split_information": split_information,
311
+ "summary": summary.to_dict(orient="records"),
312
+ "model_results": {
313
+ name: {
314
+ key: value
315
+ for key, value in record.items()
316
+ if key not in ("predictions", "configuration")
317
+ }
318
+ for name, record in model_results.items()
319
+ },
320
+ "confusion_matrices": {
321
+ name: matrix.tolist() for name, matrix in confusion_matrices.items()
322
+ },
323
+ "class_names": list(class_names),
324
+ "warnings": warnings,
325
+ }
326
+ _write_json(os.path.join(root, "benchmark_metrics.json"), metrics_payload)
327
+
328
+ try:
329
+ torch_version = __import__("torch").__version__
330
+ except Exception: # pragma: no cover
331
+ torch_version = "unavailable"
332
+
333
+ run_configuration = {
334
+ "package_information": {"name": PACKAGE_NAME, "version": __version__},
335
+ "internal_constants": {
336
+ "image_size": [IMAGE_SIZE, IMAGE_SIZE],
337
+ "aspect_ratio_preserved": True,
338
+ "padding": "centred zero padding",
339
+ "pixel_range": [0, 1],
340
+ "random_seed": RANDOM_SEED,
341
+ "test_size": TEST_SIZE,
342
+ "stratified": True,
343
+ "output_directory": OUTPUT_DIR,
344
+ "cnn_max_epochs": CNN_MAX_EPOCHS,
345
+ "cnn_batch_size": CNN_BATCH_SIZE,
346
+ "cnn_early_stopping_patience": CNN_EARLY_STOPPING_PATIENCE,
347
+ "data_augmentation": False,
348
+ "pretrained_weights": False,
349
+ },
350
+ "dataset_information": dataset_information,
351
+ "split_information": split_information,
352
+ "model_configuration": {
353
+ name: record.get("configuration", {}) for name, record in model_results.items()
354
+ },
355
+ "model_status": {
356
+ name: {"status": record.get("status"), "error": record.get("error")}
357
+ for name, record in model_results.items()
358
+ },
359
+ "environment": {
360
+ "python": platform.python_version(),
361
+ "platform": platform.platform(),
362
+ "numpy": np.__version__,
363
+ "scikit_learn": sklearn.__version__,
364
+ "torch": torch_version,
365
+ },
366
+ }
367
+ _write_json(os.path.join(root, "run_configuration.json"), run_configuration)
368
+
369
+ plot_class_distribution(
370
+ {
371
+ "Full dataset": split_information["full_class_distribution"],
372
+ "Training": split_information["training_class_distribution"],
373
+ "Testing": split_information["testing_class_distribution"],
374
+ },
375
+ os.path.join(root, "class_distribution.png"),
376
+ )
377
+ plot_model_comparison(summary, os.path.join(root, "model_comparison.png"))
378
+
379
+ for name in MODEL_ORDER:
380
+ stem = MODEL_FILE_STEMS[name]
381
+ matrix = confusion_matrices.get(name)
382
+ if matrix is None:
383
+ matrix = np.zeros((len(class_names), len(class_names)), dtype=int)
384
+ plot_confusion_matrix(
385
+ matrix, class_names, name, os.path.join(matrices_dir, "%s.png" % stem)
386
+ )
387
+
388
+ report = classification_reports.get(name)
389
+ report_path = os.path.join(reports_dir, "%s.csv" % stem)
390
+ if report is None:
391
+ import pandas as pd
392
+
393
+ pd.DataFrame(
394
+ {
395
+ "class": list(class_names),
396
+ "precision": [float("nan")] * len(class_names),
397
+ "recall": [float("nan")] * len(class_names),
398
+ "f1-score": [float("nan")] * len(class_names),
399
+ "support": [float("nan")] * len(class_names),
400
+ "status": ["model failed"] * len(class_names),
401
+ }
402
+ ).to_csv(report_path, index=False)
403
+ else:
404
+ report.to_csv(report_path, index=False)
405
+
406
+ if best_model and model_results.get(best_model, {}).get("status") == "success":
407
+ plot_prediction_examples(
408
+ images_test,
409
+ y_test,
410
+ model_results[best_model]["predictions"],
411
+ class_names,
412
+ best_model,
413
+ os.path.join(root, "prediction_examples.png"),
414
+ )
415
+
416
+ return root
@@ -0,0 +1,58 @@
1
+ """The four classical Scikit-learn classifiers.
2
+
3
+ Logistic Regression and the SVM are wrapped in a ``Pipeline`` whose
4
+ ``StandardScaler`` is fitted inside ``fit`` and therefore only ever sees
5
+ training data. Tree-based models are scale invariant and are used bare.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Dict
11
+
12
+ from .config import RANDOM_SEED
13
+
14
+
15
+ def build_classical_models() -> Dict[str, object]:
16
+ """Return {display name: unfitted estimator} for the classical models."""
17
+ from sklearn.ensemble import RandomForestClassifier
18
+ from sklearn.linear_model import LogisticRegression
19
+ from sklearn.pipeline import Pipeline
20
+ from sklearn.preprocessing import StandardScaler
21
+ from sklearn.svm import SVC
22
+ from sklearn.tree import DecisionTreeClassifier
23
+
24
+ return {
25
+ "Logistic Regression": Pipeline(
26
+ [
27
+ ("scaler", StandardScaler()),
28
+ ("classifier", LogisticRegression(max_iter=1000, random_state=RANDOM_SEED)),
29
+ ]
30
+ ),
31
+ "Decision Tree": DecisionTreeClassifier(random_state=RANDOM_SEED),
32
+ "Random Forest": RandomForestClassifier(
33
+ n_estimators=200, random_state=RANDOM_SEED, n_jobs=-1
34
+ ),
35
+ "SVM": Pipeline(
36
+ [
37
+ ("scaler", StandardScaler()),
38
+ ("classifier", SVC(kernel="rbf", random_state=RANDOM_SEED)),
39
+ ]
40
+ ),
41
+ }
42
+
43
+
44
+ def describe(estimator) -> dict:
45
+ """Flat, JSON-safe view of an estimator's effective parameters."""
46
+ try:
47
+ params = estimator.get_params(deep=True)
48
+ except AttributeError:
49
+ return {"repr": repr(estimator)}
50
+
51
+ described = {}
52
+ for key, value in sorted(params.items()):
53
+ if isinstance(value, (str, int, float, bool)) or value is None:
54
+ described[key] = value
55
+ else:
56
+ described[key] = repr(value)
57
+ described["estimator"] = type(estimator).__name__
58
+ return described
@@ -0,0 +1,56 @@
1
+ """Internal, non-configurable constants for the benchmark.
2
+
3
+ The assignment requires a four-input public API, so image size, random seed,
4
+ training epochs and output location are fixed here rather than exposed as
5
+ function parameters. Every value in this module is copied verbatim into
6
+ ``benchmark_results/run_configuration.json`` on each run.
7
+ """
8
+
9
+ PACKAGE_NAME = "Justin_Williams_CV_Benchmarking"
10
+ IMPORT_NAME = "justin_williams_cv_benchmarking"
11
+
12
+ # --- image standardisation -------------------------------------------------
13
+ IMAGE_SIZE = 64 # every image becomes IMAGE_SIZE x IMAGE_SIZE
14
+ PAD_COLOR = 0 # aspect ratio is preserved by zero padding
15
+ PIXEL_SCALE = 255.0 # normalise raw 8-bit pixels into [0, 1]
16
+
17
+ # --- reproducibility -------------------------------------------------------
18
+ RANDOM_SEED = 42
19
+
20
+ # --- stratified split ------------------------------------------------------
21
+ TEST_SIZE = 0.20 # 80% train / 20% test, stratified by label
22
+ CNN_VALIDATION_SIZE = 0.10 # carved out of the TRAINING portion only
23
+
24
+ # --- neural network training ----------------------------------------------
25
+ CNN_MAX_EPOCHS = 20
26
+ CNN_BATCH_SIZE = 32
27
+ CNN_EARLY_STOPPING_PATIENCE = 3
28
+ CNN_LEARNING_RATE = 1e-3
29
+ CNN_DROPOUT = 0.30
30
+
31
+ MLP_HIDDEN_LAYERS = (128, 64)
32
+ MLP_MAX_ITER = 100
33
+
34
+ # --- dataset validation ----------------------------------------------------
35
+ MIN_IMAGES_PER_CLASS = 5 # recommended minimum; below this we warn
36
+ MIN_CLASSES = 2 # below this we raise
37
+ SUPPORTED_EXTENSIONS = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff")
38
+ IMAGE_PATH_COLUMN = "image_path"
39
+
40
+ # --- output ----------------------------------------------------------------
41
+ OUTPUT_DIR = "benchmark_results"
42
+ CONFUSION_MATRIX_DIR = "confusion_matrices"
43
+ CLASSIFICATION_REPORT_DIR = "classification_reports"
44
+
45
+ # --- model registry --------------------------------------------------------
46
+ # Display name -> output file stem. Order is the reporting order used before
47
+ # the summary table is sorted by Macro F1-score.
48
+ MODEL_FILE_STEMS = {
49
+ "Logistic Regression": "logistic_regression",
50
+ "Decision Tree": "decision_tree",
51
+ "Random Forest": "random_forest",
52
+ "SVM": "svm",
53
+ "Neural Network": "neural_network",
54
+ "Simple CNN": "simple_cnn",
55
+ }
56
+ MODEL_ORDER = tuple(MODEL_FILE_STEMS)