lispyn 0.17.0a1__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.
lispyn/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Lispyn: Lisp-powered experiments and notebooks.
2
+
3
+ Copyright 2026 Eduardo J. Barrios. SPDX-License-Identifier: Apache-2.0.
4
+ """
5
+
6
+ __version__ = "0.17.0a1"
7
+ __author__ = "Eduardo J. Barrios"
@@ -0,0 +1,170 @@
1
+ """Classification evaluation for PyTorch/Jupyter without a sklearn dependency.
2
+
3
+ Copyright 2026 Eduardo J. Barrios. SPDX-License-Identifier: Apache-2.0.
4
+
5
+ Confusion matrices use existing labels and predictions; this module does
6
+ not run inference, train a model, or download anything.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from functools import partial
12
+ from pathlib import Path
13
+
14
+ from .plots import _design, _figure, _positive
15
+
16
+
17
+ def _scalar(value):
18
+ """Convert scalar tensor/NumPy values to ordinary Python label values."""
19
+ if hasattr(value, "item"):
20
+ try:
21
+ return value.item()
22
+ except (TypeError, ValueError):
23
+ pass
24
+ return value
25
+
26
+
27
+ def confusion_counts(actual, predicted, *, classes=None):
28
+ """Return (counts, classes) with rows=actual and columns=predicted.
29
+
30
+ The original order of class labels is preserved unless classes explicitly
31
+ specifies the desired order. Mis-typed or unseen classes are rejected
32
+ instead of silently dropping observations.
33
+ """
34
+ if isinstance(actual, (str, bytes)) or isinstance(predicted, (str, bytes)):
35
+ raise ValueError("actual and predicted must be sequences of class labels")
36
+ try:
37
+ actual = [_scalar(x) for x in actual]
38
+ predicted = [_scalar(x) for x in predicted]
39
+ except (TypeError, ValueError) as exc:
40
+ raise ValueError("actual and predicted must be one-dimensional sequences") from exc
41
+ if not actual or len(actual) != len(predicted):
42
+ raise ValueError("actual and predicted must have equal, nonzero lengths")
43
+
44
+ observed = []
45
+ try:
46
+ for label in actual + predicted:
47
+ hash(label)
48
+ if label not in observed:
49
+ observed.append(label)
50
+ except TypeError as exc:
51
+ raise ValueError("class labels must be hashable scalar values") from exc
52
+
53
+ if classes is None:
54
+ classes = observed
55
+ else:
56
+ if isinstance(classes, (str, bytes)):
57
+ raise ValueError("classes must be an ordered sequence, not a string")
58
+ try:
59
+ classes = [_scalar(label) for label in classes]
60
+ unique = set(classes)
61
+ except (TypeError, ValueError) as exc:
62
+ raise ValueError("classes must contain hashable scalar labels") from exc
63
+ if not classes or len(unique) != len(classes):
64
+ raise ValueError("classes must contain unique, nonempty class labels")
65
+ if set(observed) - set(classes):
66
+ raise ValueError("classes must include every observed class")
67
+
68
+ positions = {label: idx for idx, label in enumerate(classes)}
69
+ counts = [[0 for _ in classes] for _ in classes]
70
+ for truth, guess in zip(actual, predicted):
71
+ counts[positions[truth]][positions[guess]] += 1
72
+ return counts, classes
73
+
74
+
75
+ def normalized_counts(counts, mode=None):
76
+ """Normalize counts by actual row, predicted column, or all samples."""
77
+ if mode not in (None, "true", "pred", "all"):
78
+ raise ValueError("normalize must be None, 'true', 'pred', or 'all'")
79
+ if mode is None:
80
+ return [[int(value) for value in row] for row in counts]
81
+ total = sum(sum(row) for row in counts)
82
+ row_sums = [sum(row) for row in counts]
83
+ col_sums = [sum(row[idx] for row in counts) for idx in range(len(counts))]
84
+ result = []
85
+ for row_idx, row in enumerate(counts):
86
+ out = []
87
+ for col_idx, value in enumerate(row):
88
+ denom = (
89
+ row_sums[row_idx] if mode == "true"
90
+ else col_sums[col_idx] if mode == "pred"
91
+ else total
92
+ )
93
+ out.append(value / denom if denom else 0.0)
94
+ result.append(out)
95
+ return result
96
+
97
+
98
+ def plot_confusion_matrix(actual, predicted, *, classes=None, normalize=None,
99
+ theme="docusaurus-dark", palette="turquoise-spectrum",
100
+ title="Confusion matrix", subtitle=None,
101
+ xlabel="Predicted", ylabel="Actual",
102
+ figsize=(7, 5.6), title_size=16, label_size=11,
103
+ tick_size=10, annotate=True, colorbar=True,
104
+ save=None, dpi=150):
105
+ """Render a themed confusion matrix from actual/predicted classification labels.
106
+
107
+ Returns Matplotlib Figure for customization or inline Jupyter display.
108
+ 'true' normalization divides by row support, 'pred' by column support,
109
+ 'all' by total samples. Empty denominators are displayed as zero.
110
+ """
111
+ counts, names = confusion_counts(actual, predicted, classes=classes)
112
+ matrix = normalized_counts(counts, normalize)
113
+ if not isinstance(annotate, bool) or not isinstance(colorbar, bool):
114
+ raise ValueError("annotate and colorbar must be booleans")
115
+ _positive("dpi", dpi, 600)
116
+ design = _design(theme=theme, palette=palette)
117
+ try:
118
+ import matplotlib.pyplot as plt
119
+ from matplotlib.colors import LinearSegmentedColormap
120
+ except ImportError as exc:
121
+ raise RuntimeError('Confusion matrix plotting requires: pip install -e ".[plots]"') from exc
122
+
123
+ # All shades derive from the requested palette; the lowest intensities
124
+ # blend into the dark axes while the highest become bright turquoise.
125
+ from .plots import PALETTES
126
+ swatches = PALETTES[palette] if palette is not None else (
127
+ design["axes_bg"], design["train_color"], design["val_color"]
128
+ )
129
+ cmap = LinearSegmentedColormap.from_list(
130
+ "lispyn-confusion", [design["axes_bg"], *swatches]
131
+ )
132
+ fig, ax = _figure(
133
+ figsize=figsize, design=design, title=title, subtitle=subtitle,
134
+ xlabel=xlabel, ylabel=ylabel, grid=False, title_size=title_size,
135
+ label_size=label_size, tick_size=tick_size,
136
+ )
137
+ maximum = max(max(row) for row in matrix)
138
+ maximum = max(maximum, 1 if normalize is not None else 1)
139
+ image = ax.imshow(matrix, interpolation="nearest", cmap=cmap,
140
+ vmin=0, vmax=maximum, aspect="equal")
141
+ ax.set_xticks(list(range(len(names))))
142
+ ax.set_yticks(list(range(len(names))))
143
+ ax.set_xticklabels([str(x) for x in names], rotation=30, ha="right")
144
+ ax.set_yticklabels([str(x) for x in names])
145
+ ax.tick_params(length=0, colors=design["text_color"])
146
+ if annotate:
147
+ for row_idx, row in enumerate(matrix):
148
+ for col_idx, value in enumerate(row):
149
+ text_color = (
150
+ "#152129" if value / maximum > 0.58
151
+ else design["text_color"]
152
+ )
153
+ text = f"{value:.2f}" if normalize is not None else str(value)
154
+ ax.text(col_idx, row_idx, text, color=text_color,
155
+ ha="center", va="center", fontsize=tick_size)
156
+ if colorbar:
157
+ colorbar_obj = fig.colorbar(image, ax=ax, shrink=0.78, pad=0.035)
158
+ colorbar_obj.ax.tick_params(colors=design["text_color"])
159
+ colorbar_obj.outline.set_edgecolor(design["spine_color"])
160
+ fig.tight_layout(rect=(0, 0, 1, 0.93 if fig._suptitle else 1))
161
+ if save is not None:
162
+ output = Path(save).expanduser()
163
+ output.parent.mkdir(parents=True, exist_ok=True)
164
+ fig.savefig(output, facecolor=fig.get_facecolor(), dpi=dpi)
165
+ return fig
166
+
167
+
168
+ def make_confusion_plotter(**defaults):
169
+ """Return a reusable confusion-matrix plotter, customizable per call."""
170
+ return partial(plot_confusion_matrix, **defaults)
lispyn/cli.py ADDED
@@ -0,0 +1,126 @@
1
+ """Command-line interface for Lispyn.
2
+
3
+ Copyright 2026 Eduardo J. Barrios. SPDX-License-Identifier: Apache-2.0.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ from pathlib import Path
11
+ import sys
12
+
13
+ from .compiler import LispynError, check_source, run_hy
14
+ from .notebooks import build_notebook, execute_notebook, write_notebook
15
+ from .reproducibility import summarize_run
16
+ from .scaffold import create_experiment
17
+ from .gallery import generate_gallery
18
+ from .doctor import inspect_environment, format_report
19
+
20
+
21
+ def parser() -> argparse.ArgumentParser:
22
+ p = argparse.ArgumentParser(
23
+ prog="lispyn", description="Hy experiments, Python notebooks, PyTorch freedom."
24
+ )
25
+ p.add_argument("--version", action="version", version="lispyn 0.17.0a1")
26
+ sub = p.add_subparsers(dest="command", required=True)
27
+ health = sub.add_parser("doctor", help="Inspect installation and optional tools without modifying anything")
28
+ health.add_argument("--json", action="store_true", help="Print machine-readable environment summary")
29
+ for name, help_text in [
30
+ ("build", "Generate a Python Jupyter notebook without executing it"),
31
+ ("execute", "Generate, execute, and save the Jupyter notebook"),
32
+ ("run", "Run the Hy file directly without creating a notebook"),
33
+ ]:
34
+ action = sub.add_parser(name, help=help_text)
35
+ action.add_argument("source", type=Path, help="Trusted .hy source file")
36
+ if name != "run":
37
+ action.add_argument("-o", "--output", type=Path, default=None)
38
+ if name == "execute":
39
+ action.add_argument("--timeout", type=int, default=900, help="Per-cell timeout in seconds")
40
+ action.add_argument("--kernel", default="python3", help="Installed Jupyter kernel name")
41
+ gallery = sub.add_parser(
42
+ "gallery", help="Compile examples into example_generated_notebooks/ without execution"
43
+ )
44
+ gallery.add_argument("--source-dir", type=Path, default=Path("examples"))
45
+ gallery.add_argument("--output-dir", type=Path, default=Path("example_generated_notebooks"))
46
+ starter = sub.add_parser("new", help="Create a ready-to-edit Hy/Jupyter experiment")
47
+ starter.add_argument("source", type=Path, help="Path for a new .hy file")
48
+ report = sub.add_parser("report", help="Summarize a saved Lispyn experiment")
49
+ report.add_argument("directory", type=Path, help="Directory with manifest.json")
50
+ report.add_argument("--plot", type=Path, help="Save an optional PNG/SVG metric plot")
51
+ report.add_argument("--metric", default="loss", help="Metric to plot (default: loss)")
52
+ report.add_argument("--theme", default="clean", choices=["clean", "dark", "paper", "docusaurus-dark"])
53
+ report.add_argument("--title", help="Custom plot title")
54
+ report.add_argument("--subtitle", help="Optional secondary title")
55
+ report.add_argument("--palette", choices=["turquoise-spectrum"],
56
+ help="Named color palette for the curves")
57
+ report.add_argument("--linewidth", type=float, default=2.2)
58
+ report.add_argument("--fill-alpha", type=float, default=0.0)
59
+ report.add_argument("--xlabel", default="Epoch", help="Horizontal axis label")
60
+ report.add_argument("--ylabel", help="Vertical axis label")
61
+ report.add_argument("--train-color", help="Matplotlib training-curve color")
62
+ report.add_argument("--val-color", help="Matplotlib validation-curve color")
63
+ return p
64
+
65
+
66
+ def main(argv: list[str] | None = None) -> int:
67
+ args = parser().parse_args(argv)
68
+ try:
69
+ if args.command == "doctor":
70
+ report = inspect_environment()
71
+ print(json.dumps(report, indent=2) if args.json else format_report(report))
72
+ return 0
73
+ if args.command == "gallery":
74
+ paths = generate_gallery(args.source_dir, args.output_dir)
75
+ for path in paths:
76
+ print(f"Generated: {path}")
77
+ return 0
78
+ if args.command == "new":
79
+ print(f"Created: {create_experiment(args.source)}")
80
+ return 0
81
+ if args.command == "report":
82
+ print(summarize_run(args.directory))
83
+ if args.plot is not None:
84
+ from .plots import plot_history
85
+ fig = plot_history(args.directory / "history.json",
86
+ metric=args.metric, save=args.plot,
87
+ theme=args.theme, title=args.title, subtitle=args.subtitle,
88
+ palette=args.palette, linewidth=args.linewidth,
89
+ fill_alpha=args.fill_alpha,
90
+ xlabel=args.xlabel, ylabel=args.ylabel,
91
+ train_color=args.train_color,
92
+ val_color=args.val_color)
93
+ import matplotlib.pyplot as plt
94
+ plt.close(fig)
95
+ print(f"Plot saved: {args.plot.expanduser().resolve()}")
96
+ return 0
97
+ source = check_source(args.source)
98
+ if args.command == "run":
99
+ return run_hy(source)
100
+ output = args.output or source.with_suffix(".ipynb")
101
+ notebook = build_notebook(source)
102
+ if args.command == "build":
103
+ print(f"Generated: {write_notebook(notebook, output)}")
104
+ return 0
105
+ if args.timeout < -1:
106
+ raise LispynError("--timeout must be -1 (unlimited) or a nonnegative number")
107
+ # Save the notebook before running so the generated artifact survives
108
+ # kernel startup errors, and save available outputs after cell errors.
109
+ write_notebook(notebook, output)
110
+ try:
111
+ execute_notebook(notebook, cwd=source.parent, timeout=args.timeout, kernel=args.kernel)
112
+ finally:
113
+ write_notebook(notebook, output)
114
+ print(f"Executed and saved: {Path(output).resolve()}")
115
+ return 0
116
+ except (LispynError, OSError) as exc:
117
+ print(f"lispyn: {exc}", file=sys.stderr)
118
+ return 1
119
+ except Exception as exc:
120
+ # A failed notebook should not be reported as a successful run.
121
+ print(f"lispyn: execution failed: {exc}", file=sys.stderr)
122
+ return 1
123
+
124
+
125
+ if __name__ == "__main__":
126
+ raise SystemExit(main())
lispyn/compiler.py ADDED
@@ -0,0 +1,68 @@
1
+ """Compile a trusted Hy file into Python using Hy's official CLI.
2
+
3
+ Copyright 2026 Eduardo J. Barrios. SPDX-License-Identifier: Apache-2.0.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+
13
+
14
+ def find_hy_executable(name: str) -> str | None:
15
+ """Find the Hy script in the *current Python environment*, even if its
16
+ bin/Scripts directory has not been activated in PATH.
17
+ """
18
+ scripts = Path(sys.executable).parent
19
+ for candidate in (scripts / name, scripts / (name + ".exe")):
20
+ if candidate.is_file():
21
+ return str(candidate)
22
+ return shutil.which(name)
23
+
24
+
25
+ class LispynError(Exception):
26
+ """An expected error while building or running a Lispyn experiment."""
27
+
28
+
29
+ def check_source(source: Path) -> Path:
30
+ source = source.expanduser().resolve()
31
+ if not source.is_file():
32
+ raise LispynError(f"File not found: {source}")
33
+ if source.suffix != ".hy":
34
+ raise LispynError("Input must be a .hy file")
35
+ return source
36
+
37
+
38
+ def compile_hy(source: Path, *, cwd: Path | None = None) -> str:
39
+ """Translate a Hy source file to Python; never execute its output here.
40
+
41
+ IMPORTANT: Hy macros may run arbitrary Python at *compile time*. Only build
42
+ code you trust. This is not a sandbox.
43
+ """
44
+ source = check_source(source)
45
+ hy2py = find_hy_executable("hy2py")
46
+ if hy2py is None:
47
+ raise LispynError("hy2py not found in this Python environment. Install Hy and Lispyn.")
48
+ result = subprocess.run(
49
+ [hy2py, str(source)],
50
+ cwd=cwd or source.parent,
51
+ text=True,
52
+ capture_output=True,
53
+ check=False,
54
+ )
55
+ if result.returncode:
56
+ raise LispynError(
57
+ "Hy compilation failed:\n" + (result.stderr.strip() or result.stdout.strip())
58
+ )
59
+ return result.stdout
60
+
61
+
62
+ def run_hy(source: Path) -> int:
63
+ """Execute trusted Hy source without creating a notebook."""
64
+ source = check_source(source)
65
+ hy = find_hy_executable("hy")
66
+ if hy is None:
67
+ raise LispynError("hy not found in this Python environment. Install Hy and Lispyn.")
68
+ return subprocess.run([hy, str(source)], cwd=source.parent, check=False).returncode
lispyn/data.py ADDED
@@ -0,0 +1,185 @@
1
+ """Opt-in dataset utilities for small, reproducible PyTorch experiments.
2
+
3
+ Copyright 2026 Eduardo J. Barrios. SPDX-License-Identifier: Apache-2.0.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+ from typing import Sequence
10
+ import random
11
+
12
+
13
+ def read_dataset(path: str | Path, **kwargs):
14
+ """Load CSV, JSON, JSON Lines or Parquet as a pandas DataFrame.
15
+
16
+ pandas is optional: ``pip install -e '.[data]'``. Parquet also requires
17
+ an installed backend (typically pyarrow). File data is not executed.
18
+ """
19
+ try:
20
+ import pandas as pd
21
+ except ImportError as exc:
22
+ raise RuntimeError("Install pandas first: pip install -e '.[data]'") from exc
23
+
24
+ path = Path(path).expanduser()
25
+ readers = {
26
+ ".csv": pd.read_csv,
27
+ ".json": pd.read_json,
28
+ ".jsonl": pd.read_json,
29
+ ".parquet": pd.read_parquet,
30
+ }
31
+ reader = readers.get(path.suffix.lower())
32
+ if reader is None:
33
+ raise ValueError(f"Unsupported dataset extension: {path.suffix}")
34
+ if path.suffix.lower() == ".jsonl":
35
+ kwargs.setdefault("lines", True)
36
+ return reader(path, **kwargs)
37
+
38
+
39
+ def drop_missing(frame, *, columns: Sequence[str] | None = None):
40
+ """Return a new DataFrame with incomplete rows removed."""
41
+ return frame.dropna(subset=list(columns) if columns is not None else None).reset_index(drop=True)
42
+
43
+
44
+ def train_validation_split(frame, *, validation_fraction: float = 0.2,
45
+ seed: int = 42, shuffle: bool = True):
46
+ """Reproducible row-level train/validation split; no sklearn needed.
47
+
48
+ Splits at least one row into each partition. This is not stratified or
49
+ group-aware: users must choose a different split for time-series or
50
+ datasets that could leak information between groups.
51
+ """
52
+ if not 0 < validation_fraction < 1:
53
+ raise ValueError("validation_fraction must be strictly between 0 and 1")
54
+ if len(frame) < 2:
55
+ raise ValueError("At least two rows are needed for a train/validation split")
56
+ shuffled = frame.sample(frac=1, random_state=seed) if shuffle else frame
57
+ validation_rows = max(1, min(len(frame) - 1, int(round(len(frame) * validation_fraction))))
58
+ return (
59
+ shuffled.iloc[:-validation_rows].reset_index(drop=True),
60
+ shuffled.iloc[-validation_rows:].reset_index(drop=True),
61
+ )
62
+
63
+
64
+
65
+ def split_dataset(dataset, *, validation_fraction: float = 0.2,
66
+ seed: int = 42, stratify: bool = False, labels=None):
67
+ """Split any indexable PyTorch dataset into two non-overlapping Subsets.
68
+
69
+ Set stratify=True to read dataset.targets (ImageFolder, AudioFolder,
70
+ labeled Lispyn text datasets). Or pass labels explicitly, which also
71
+ enables stratification; e.g. labels=[0, 1, ...] for TensorDataset.
72
+
73
+ A class must contain >=2 samples so both partitions contain that class.
74
+ Splitting is sample-level, not group-, speaker- or subject-aware: choose
75
+ a grouped split when images/audio/text from one source can leak across
76
+ partitions. Per-class rounding can change the overall validation fraction.
77
+ """
78
+ from torch.utils.data import Subset
79
+
80
+ if isinstance(validation_fraction, bool) or not 0 < validation_fraction < 1:
81
+ raise ValueError("validation_fraction must be strictly between 0 and 1")
82
+ if isinstance(seed, bool) or not isinstance(seed, int):
83
+ raise ValueError("seed must be an integer")
84
+ count = len(dataset)
85
+ if count < 2:
86
+ raise ValueError("At least two samples are needed for a dataset split")
87
+ rng = random.Random(seed)
88
+
89
+ if stratify or labels is not None:
90
+ if labels is None:
91
+ labels = getattr(dataset, "targets", None)
92
+ if labels is None:
93
+ raise ValueError("Stratified split requires labels or dataset.targets")
94
+ labels = list(labels)
95
+ if len(labels) != count:
96
+ raise ValueError("Number of labels must match dataset length")
97
+ groups = {}
98
+ try:
99
+ for index, label in enumerate(labels):
100
+ if hasattr(label, "item"):
101
+ label = label.item()
102
+ groups.setdefault(label, []).append(index)
103
+ except TypeError as exc:
104
+ raise ValueError("Class labels must be hashable scalars") from exc
105
+
106
+ train_indices, valid_indices = [], []
107
+ for group in groups.values():
108
+ if len(group) < 2:
109
+ raise ValueError(
110
+ "Stratified split needs at least two samples per class"
111
+ )
112
+ rng.shuffle(group)
113
+ n_valid = max(1, min(len(group) - 1,
114
+ round(len(group) * validation_fraction)))
115
+ valid_indices.extend(group[:n_valid])
116
+ train_indices.extend(group[n_valid:])
117
+ rng.shuffle(train_indices)
118
+ rng.shuffle(valid_indices)
119
+ else:
120
+ indices = list(range(count))
121
+ rng.shuffle(indices)
122
+ n_valid = max(1, min(count - 1, round(count * validation_fraction)))
123
+ valid_indices = indices[:n_valid]
124
+ train_indices = indices[n_valid:]
125
+ return Subset(dataset, train_indices), Subset(dataset, valid_indices)
126
+
127
+
128
+ def to_tensor(values, *, dtype=None, device=None):
129
+ """Convert values into a tensor using the real, unrestricted torch API."""
130
+ try:
131
+ import torch
132
+ except ImportError as exc:
133
+ raise RuntimeError("Install PyTorch separately for your platform") from exc
134
+ return torch.as_tensor(values, dtype=dtype, device=device)
135
+
136
+
137
+ def dataframe_to_dataset(frame, *, features: Sequence[str], target: str,
138
+ feature_dtype=None, target_dtype=None):
139
+ """Turn numeric DataFrame columns into a PyTorch TensorDataset.
140
+
141
+ Features default to torch.float32; target defaults to torch.float32 for
142
+ regression. For classification, pass target_dtype=torch.long. Target is
143
+ a 1-D tensor, and the original DataFrame is not modified.
144
+ """
145
+ try:
146
+ import torch
147
+ except ImportError as exc:
148
+ raise RuntimeError("Install PyTorch separately for your platform") from exc
149
+ features = list(features)
150
+ if not features:
151
+ raise ValueError("features must include at least one column")
152
+ if target in features:
153
+ raise ValueError("target must not also be a feature (target leakage)")
154
+ missing = [name for name in [*features, target] if name not in frame.columns]
155
+ if missing:
156
+ raise ValueError(f"Missing dataset columns: {missing}")
157
+ x = torch.as_tensor(
158
+ frame[features].to_numpy(dtype="float32", copy=True),
159
+ dtype=feature_dtype or torch.float32,
160
+ )
161
+ y = torch.as_tensor(
162
+ frame[target].to_numpy(copy=True), dtype=target_dtype or torch.float32
163
+ )
164
+ return torch.utils.data.TensorDataset(x, y)
165
+
166
+
167
+ def make_dataloader(dataset, *, batch_size: int = 32, shuffle: bool = False,
168
+ seed: int | None = None, **kwargs):
169
+ """Build a native PyTorch DataLoader, optionally with seeded shuffling.
170
+
171
+ Arbitrary PyTorch DataLoader keyword options can pass through ``kwargs``.
172
+ """
173
+ try:
174
+ import torch
175
+ except ImportError as exc:
176
+ raise RuntimeError("Install PyTorch separately for your platform") from exc
177
+ if batch_size < 1:
178
+ raise ValueError("batch_size must be positive")
179
+ if seed is not None:
180
+ if "generator" in kwargs:
181
+ raise ValueError("Pass either seed or generator, not both")
182
+ kwargs["generator"] = torch.Generator().manual_seed(seed)
183
+ return torch.utils.data.DataLoader(
184
+ dataset, batch_size=batch_size, shuffle=shuffle, **kwargs
185
+ )