silver-torch 0.1.0__tar.gz
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.
- silver_torch-0.1.0/PKG-INFO +72 -0
- silver_torch-0.1.0/README.md +59 -0
- silver_torch-0.1.0/pyproject.toml +23 -0
- silver_torch-0.1.0/setup.cfg +4 -0
- silver_torch-0.1.0/src/silver_torch/__init__.py +6 -0
- silver_torch-0.1.0/src/silver_torch/pipeline.py +302 -0
- silver_torch-0.1.0/src/silver_torch/spec.py +50 -0
- silver_torch-0.1.0/src/silver_torch.egg-info/PKG-INFO +72 -0
- silver_torch-0.1.0/src/silver_torch.egg-info/SOURCES.txt +11 -0
- silver_torch-0.1.0/src/silver_torch.egg-info/dependency_links.txt +1 -0
- silver_torch-0.1.0/src/silver_torch.egg-info/requires.txt +6 -0
- silver_torch-0.1.0/src/silver_torch.egg-info/top_level.txt +1 -0
- silver_torch-0.1.0/tests/test_pipeline.py +141 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: silver-torch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Inspectable Silver dataset preprocessing and PyTorch input pipelines.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Keywords: pytorch,preprocessing,datasets,machine-learning,research
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Provides-Extra: pytorch
|
|
10
|
+
Requires-Dist: torch>=1.9.0; extra == "pytorch"
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
13
|
+
|
|
14
|
+
# silver-torch
|
|
15
|
+
|
|
16
|
+
An optional PyTorch layer for Silver. It turns a small Silver preprocessing
|
|
17
|
+
program into a fitted, inspectable, reusable tensor and `DataLoader` pipeline.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install silver-data
|
|
21
|
+
pip install 'silver-torch[pytorch]'
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from silver_data import Dataset
|
|
26
|
+
from silver_torch import compile_silver
|
|
27
|
+
|
|
28
|
+
program = """
|
|
29
|
+
pipeline ieee_inverse:
|
|
30
|
+
features voltage, current, phase, sensor
|
|
31
|
+
categorical sensor
|
|
32
|
+
label fault
|
|
33
|
+
architecture transformer
|
|
34
|
+
sequence_length 2
|
|
35
|
+
scaling standard
|
|
36
|
+
missing median
|
|
37
|
+
label_type classification
|
|
38
|
+
batch_size 128
|
|
39
|
+
num_workers 2
|
|
40
|
+
cache_dir .cache/ieee_inverse
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
dataset = Dataset.from_records("ieee", [
|
|
44
|
+
{"voltage": 1.0, "current": 2.0, "phase": 0.2, "fault": 0},
|
|
45
|
+
{"voltage": 1.2, "current": 2.1, "phase": 0.3, "fault": 1},
|
|
46
|
+
])
|
|
47
|
+
splits = dataset.split(0.8, 0.1, 0.1)
|
|
48
|
+
pipeline = compile_silver(program).fit(splits.train.records())
|
|
49
|
+
loader = pipeline.dataloader(splits.validation.records(), device="cuda")
|
|
50
|
+
print(pipeline.plan(device="cuda").to_dict())
|
|
51
|
+
print(pipeline.benchmark(splits.validation.records(), steps=20))
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The compiler has explicit research-safety boundaries:
|
|
55
|
+
|
|
56
|
+
- statistics and vocabularies are fitted only on `splits.train`;
|
|
57
|
+
- missing columns, non-finite numbers, invalid labels, and incompatible
|
|
58
|
+
sequence lengths fail loudly;
|
|
59
|
+
- categorical vocabularies are sorted for reproducibility and reserve index 0
|
|
60
|
+
for unknown values;
|
|
61
|
+
- classification targets are `torch.long`; regression targets use the chosen
|
|
62
|
+
floating dtype;
|
|
63
|
+
- cache keys include the fitted-training fingerprint and transformed rows, and
|
|
64
|
+
cache writes are atomic;
|
|
65
|
+
- loaders are seeded and tune pinning, persistent workers, prefetching, and
|
|
66
|
+
`drop_last` based on the declared runtime.
|
|
67
|
+
|
|
68
|
+
The emitted shapes are `[batch, features]` for MLP, `[batch, 1, features]` for
|
|
69
|
+
CNN, and `[batch, sequence_length, features_per_step]` for RNN/Transformer.
|
|
70
|
+
These are layout contracts, not model implementations. Measure with
|
|
71
|
+
`benchmark()` on the target machine; input speedups depend on storage, CPU,
|
|
72
|
+
worker count, batch size, and accelerator.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# silver-torch
|
|
2
|
+
|
|
3
|
+
An optional PyTorch layer for Silver. It turns a small Silver preprocessing
|
|
4
|
+
program into a fitted, inspectable, reusable tensor and `DataLoader` pipeline.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install silver-data
|
|
8
|
+
pip install 'silver-torch[pytorch]'
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from silver_data import Dataset
|
|
13
|
+
from silver_torch import compile_silver
|
|
14
|
+
|
|
15
|
+
program = """
|
|
16
|
+
pipeline ieee_inverse:
|
|
17
|
+
features voltage, current, phase, sensor
|
|
18
|
+
categorical sensor
|
|
19
|
+
label fault
|
|
20
|
+
architecture transformer
|
|
21
|
+
sequence_length 2
|
|
22
|
+
scaling standard
|
|
23
|
+
missing median
|
|
24
|
+
label_type classification
|
|
25
|
+
batch_size 128
|
|
26
|
+
num_workers 2
|
|
27
|
+
cache_dir .cache/ieee_inverse
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
dataset = Dataset.from_records("ieee", [
|
|
31
|
+
{"voltage": 1.0, "current": 2.0, "phase": 0.2, "fault": 0},
|
|
32
|
+
{"voltage": 1.2, "current": 2.1, "phase": 0.3, "fault": 1},
|
|
33
|
+
])
|
|
34
|
+
splits = dataset.split(0.8, 0.1, 0.1)
|
|
35
|
+
pipeline = compile_silver(program).fit(splits.train.records())
|
|
36
|
+
loader = pipeline.dataloader(splits.validation.records(), device="cuda")
|
|
37
|
+
print(pipeline.plan(device="cuda").to_dict())
|
|
38
|
+
print(pipeline.benchmark(splits.validation.records(), steps=20))
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The compiler has explicit research-safety boundaries:
|
|
42
|
+
|
|
43
|
+
- statistics and vocabularies are fitted only on `splits.train`;
|
|
44
|
+
- missing columns, non-finite numbers, invalid labels, and incompatible
|
|
45
|
+
sequence lengths fail loudly;
|
|
46
|
+
- categorical vocabularies are sorted for reproducibility and reserve index 0
|
|
47
|
+
for unknown values;
|
|
48
|
+
- classification targets are `torch.long`; regression targets use the chosen
|
|
49
|
+
floating dtype;
|
|
50
|
+
- cache keys include the fitted-training fingerprint and transformed rows, and
|
|
51
|
+
cache writes are atomic;
|
|
52
|
+
- loaders are seeded and tune pinning, persistent workers, prefetching, and
|
|
53
|
+
`drop_last` based on the declared runtime.
|
|
54
|
+
|
|
55
|
+
The emitted shapes are `[batch, features]` for MLP, `[batch, 1, features]` for
|
|
56
|
+
CNN, and `[batch, sequence_length, features_per_step]` for RNN/Transformer.
|
|
57
|
+
These are layout contracts, not model implementations. Measure with
|
|
58
|
+
`benchmark()` on the target machine; input speedups depend on storage, CPU,
|
|
59
|
+
worker count, batch size, and accelerator.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "silver-torch"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Inspectable Silver dataset preprocessing and PyTorch input pipelines."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
keywords = ["pytorch", "preprocessing", "datasets", "machine-learning", "research"]
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
pytorch = ["torch>=1.9.0"]
|
|
17
|
+
dev = ["pytest>=7.0.0"]
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["src"]
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.package-dir]
|
|
23
|
+
"" = "src"
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Silver's optional, inspectable PyTorch preprocessing layer."""
|
|
2
|
+
|
|
3
|
+
from .pipeline import SilverTorchPipeline, compile_silver, parse_silver
|
|
4
|
+
from .spec import SilverPreprocessSpec
|
|
5
|
+
|
|
6
|
+
__all__ = ["SilverPreprocessSpec", "SilverTorchPipeline", "compile_silver", "parse_silver"]
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Compile Silver preprocessing intent into a leakage-safe PyTorch pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
|
|
15
|
+
|
|
16
|
+
from .spec import SilverPreprocessSpec
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_silver(source: str) -> SilverPreprocessSpec:
|
|
20
|
+
"""Parse the data-pipeline subset of Silver with strict field handling."""
|
|
21
|
+
values: Dict[str, Any] = {}
|
|
22
|
+
supported = {"features", "categorical", "label", "architecture", "scaling", "missing",
|
|
23
|
+
"label_type", "dtype", "batch_size", "shuffle", "num_workers",
|
|
24
|
+
"prefetch_factor", "drop_last", "sequence_length", "seed", "cache_dir"}
|
|
25
|
+
for raw in source.splitlines():
|
|
26
|
+
line = raw.strip()
|
|
27
|
+
if not line or line.startswith("#"):
|
|
28
|
+
continue
|
|
29
|
+
if line.startswith("pipeline "):
|
|
30
|
+
match = re.fullmatch(r"pipeline\s+([A-Za-z_]\w*)\s*:", line)
|
|
31
|
+
if not match:
|
|
32
|
+
raise ValueError("pipeline declaration must look like 'pipeline name:'")
|
|
33
|
+
values["name"] = match.group(1)
|
|
34
|
+
continue
|
|
35
|
+
parts = line.split(None, 1)
|
|
36
|
+
if len(parts) != 2:
|
|
37
|
+
raise ValueError("Silver pipeline fields must look like 'field value'")
|
|
38
|
+
key, raw_value = parts
|
|
39
|
+
if key not in supported:
|
|
40
|
+
raise ValueError("unsupported Silver preprocessing field: %s" % key)
|
|
41
|
+
raw_value = raw_value.strip().strip('"')
|
|
42
|
+
if key in ("features", "categorical"):
|
|
43
|
+
values[key] = tuple(x.strip() for x in raw_value.split(",") if x.strip())
|
|
44
|
+
elif raw_value.lower() in ("true", "false"):
|
|
45
|
+
values[key] = raw_value.lower() == "true"
|
|
46
|
+
elif re.fullmatch(r"\d+", raw_value):
|
|
47
|
+
values[key] = int(raw_value)
|
|
48
|
+
else:
|
|
49
|
+
values[key] = raw_value
|
|
50
|
+
values.setdefault("name", "silver_pipeline")
|
|
51
|
+
if "features" not in values or "label" not in values:
|
|
52
|
+
raise ValueError("Silver pipeline requires 'features' and 'label'")
|
|
53
|
+
return SilverPreprocessSpec(**values)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class ColumnState:
|
|
58
|
+
kind: str
|
|
59
|
+
location: float = 0.0
|
|
60
|
+
scale: float = 1.0
|
|
61
|
+
categories: Tuple[str, ...] = ()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class CompiledPlan:
|
|
66
|
+
spec: SilverPreprocessSpec
|
|
67
|
+
feature_count: int
|
|
68
|
+
input_shape: Tuple[int, ...]
|
|
69
|
+
loader_options: Dict[str, Any]
|
|
70
|
+
fitted_rows: int
|
|
71
|
+
fingerprint: str
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
74
|
+
return {"schema": "silver.torch/plan-2", "name": self.spec.name,
|
|
75
|
+
"architecture": self.spec.architecture, "feature_count": self.feature_count,
|
|
76
|
+
"input_shape": list(self.input_shape), "label_type": self.spec.label_type,
|
|
77
|
+
"loader_options": dict(self.loader_options), "fitted_rows": self.fitted_rows,
|
|
78
|
+
"fingerprint": self.fingerprint}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SilverTorchPipeline:
|
|
82
|
+
"""Fit transforms on training rows, then reuse them without data leakage."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, spec: SilverPreprocessSpec):
|
|
85
|
+
self.spec = spec
|
|
86
|
+
self._states: Dict[str, ColumnState] = {}
|
|
87
|
+
self._label_mapping: Dict[str, int] = {}
|
|
88
|
+
self._fitted_rows = 0
|
|
89
|
+
self._fingerprint = ""
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def fitted(self) -> bool:
|
|
93
|
+
return bool(self._states)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def statistics(self) -> Dict[str, Dict[str, Any]]:
|
|
97
|
+
"""Return fitted state without exposing mutable internal objects."""
|
|
98
|
+
self._require_fit()
|
|
99
|
+
return {name: {"kind": state.kind, "location": state.location,
|
|
100
|
+
"scale": state.scale, "categories": list(state.categories)}
|
|
101
|
+
for name, state in self._states.items()}
|
|
102
|
+
|
|
103
|
+
def fit(self, rows: Iterable[Mapping[str, Any]]) -> "SilverTorchPipeline":
|
|
104
|
+
materialized = list(rows)
|
|
105
|
+
if not materialized:
|
|
106
|
+
raise ValueError("cannot fit an empty dataset")
|
|
107
|
+
missing_columns = [c for c in self.spec.features + (self.spec.label,)
|
|
108
|
+
if c not in materialized[0]]
|
|
109
|
+
if missing_columns:
|
|
110
|
+
raise ValueError("missing dataset columns: %s" % ", ".join(missing_columns))
|
|
111
|
+
states: Dict[str, ColumnState] = {}
|
|
112
|
+
for column in self.spec.features:
|
|
113
|
+
if column in self.spec.categorical:
|
|
114
|
+
categories = sorted({self._as_category(row.get(column)) for row in materialized
|
|
115
|
+
if row.get(column) not in (None, "")})
|
|
116
|
+
states[column] = ColumnState("categorical", categories=tuple(categories))
|
|
117
|
+
continue
|
|
118
|
+
numbers = [self._number(row.get(column), column) for row in materialized]
|
|
119
|
+
observed = [v for v in numbers if v is not None]
|
|
120
|
+
if not observed and self.spec.missing == "error":
|
|
121
|
+
raise ValueError("feature %r has no observed values" % column)
|
|
122
|
+
location = self._impute_location(observed)
|
|
123
|
+
clean = [v if v is not None else location for v in numbers]
|
|
124
|
+
if self.spec.scaling == "standard":
|
|
125
|
+
location = sum(clean) / len(clean)
|
|
126
|
+
scale = math.sqrt(sum((v - location) ** 2 for v in clean) / len(clean)) or 1.0
|
|
127
|
+
elif self.spec.scaling == "minmax":
|
|
128
|
+
location = min(clean)
|
|
129
|
+
scale = (max(clean) - location) or 1.0
|
|
130
|
+
else:
|
|
131
|
+
scale = 1.0
|
|
132
|
+
states[column] = ColumnState("numeric", location, scale)
|
|
133
|
+
if self.spec.label_type == "classification":
|
|
134
|
+
labels = sorted({self._as_category(row.get(self.spec.label)) for row in materialized})
|
|
135
|
+
self._label_mapping = {value: index for index, value in enumerate(labels)}
|
|
136
|
+
self._states = states
|
|
137
|
+
self._fitted_rows = len(materialized)
|
|
138
|
+
self._fingerprint = self._hash_rows(materialized)
|
|
139
|
+
return self
|
|
140
|
+
|
|
141
|
+
def plan(self, device: str = "cpu") -> CompiledPlan:
|
|
142
|
+
self._require_fit()
|
|
143
|
+
feature_count = len(self.spec.features)
|
|
144
|
+
if self.spec.architecture == "mlp":
|
|
145
|
+
shape = (feature_count,)
|
|
146
|
+
elif self.spec.architecture == "cnn":
|
|
147
|
+
shape = (1, feature_count)
|
|
148
|
+
else:
|
|
149
|
+
if feature_count % self.spec.sequence_length:
|
|
150
|
+
raise ValueError("feature count must divide sequence_length for sequential models")
|
|
151
|
+
shape = (self.spec.sequence_length, feature_count // self.spec.sequence_length)
|
|
152
|
+
options: Dict[str, Any] = {"batch_size": self.spec.batch_size, "shuffle": self.spec.shuffle,
|
|
153
|
+
"num_workers": self.spec.num_workers,
|
|
154
|
+
"pin_memory": device.startswith("cuda"),
|
|
155
|
+
"persistent_workers": self.spec.num_workers > 0,
|
|
156
|
+
"drop_last": self.spec.drop_last}
|
|
157
|
+
if self.spec.num_workers > 0:
|
|
158
|
+
options["prefetch_factor"] = self.spec.prefetch_factor
|
|
159
|
+
return CompiledPlan(self.spec, feature_count, shape, options, self._fitted_rows, self._fingerprint)
|
|
160
|
+
|
|
161
|
+
def transform(self, rows: Iterable[Mapping[str, Any]]) -> Tuple[Any, Any]:
|
|
162
|
+
"""Materialize a feature tensor and correctly typed target tensor."""
|
|
163
|
+
self._require_fit()
|
|
164
|
+
torch = _torch()
|
|
165
|
+
features: List[List[float]] = []
|
|
166
|
+
labels: List[Any] = []
|
|
167
|
+
for row in rows:
|
|
168
|
+
features.append([self._transform_value(row.get(column), column) for column in self.spec.features])
|
|
169
|
+
labels.append(self._transform_label(row.get(self.spec.label)))
|
|
170
|
+
x = torch.tensor(features, dtype=getattr(torch, self.spec.dtype)).contiguous()
|
|
171
|
+
label_dtype = torch.long if self.spec.label_type == "classification" else getattr(torch, self.spec.dtype)
|
|
172
|
+
y = torch.tensor(labels, dtype=label_dtype).contiguous()
|
|
173
|
+
if self.spec.architecture == "cnn":
|
|
174
|
+
x = x.unsqueeze(1)
|
|
175
|
+
elif self.spec.architecture in ("rnn", "transformer"):
|
|
176
|
+
x = x.reshape(len(features), self.spec.sequence_length, -1)
|
|
177
|
+
return x, y
|
|
178
|
+
|
|
179
|
+
def tensors(self, rows: Iterable[Mapping[str, Any]]) -> Tuple[Any, Any]:
|
|
180
|
+
"""Backward-compatible alias for :meth:`transform`."""
|
|
181
|
+
return self.transform(rows)
|
|
182
|
+
|
|
183
|
+
def dataloader(self, rows: Iterable[Mapping[str, Any]], device: str = "cpu") -> Any:
|
|
184
|
+
torch = _torch()
|
|
185
|
+
from torch.utils.data import DataLoader, TensorDataset
|
|
186
|
+
x, y = self._cached_or_transform(rows)
|
|
187
|
+
options = self.plan(device).loader_options
|
|
188
|
+
generator = torch.Generator()
|
|
189
|
+
generator.manual_seed(self.spec.seed)
|
|
190
|
+
options["generator"] = generator
|
|
191
|
+
return DataLoader(TensorDataset(x, y), **options)
|
|
192
|
+
|
|
193
|
+
def benchmark(self, rows: Iterable[Mapping[str, Any]], steps: int = 50,
|
|
194
|
+
device: str = "cpu") -> Dict[str, float]:
|
|
195
|
+
"""Measure input throughput, including host-to-device transfer when requested."""
|
|
196
|
+
if steps < 1:
|
|
197
|
+
raise ValueError("steps must be positive")
|
|
198
|
+
loader = self.dataloader(rows, device=device)
|
|
199
|
+
start = time.perf_counter()
|
|
200
|
+
seen = 0
|
|
201
|
+
for index, (features, labels) in enumerate(loader):
|
|
202
|
+
if device.startswith("cuda"):
|
|
203
|
+
features = features.to(device, non_blocking=True)
|
|
204
|
+
labels = labels.to(device, non_blocking=True)
|
|
205
|
+
_torch().cuda.synchronize()
|
|
206
|
+
seen += len(labels)
|
|
207
|
+
if index + 1 >= steps:
|
|
208
|
+
break
|
|
209
|
+
elapsed = max(time.perf_counter() - start, 1e-9)
|
|
210
|
+
return {"samples": float(seen), "seconds": elapsed, "samples_per_second": seen / elapsed}
|
|
211
|
+
|
|
212
|
+
def _cached_or_transform(self, rows: Iterable[Mapping[str, Any]]) -> Tuple[Any, Any]:
|
|
213
|
+
materialized = list(rows)
|
|
214
|
+
if not self.spec.cache_dir:
|
|
215
|
+
return self.transform(materialized)
|
|
216
|
+
torch = _torch()
|
|
217
|
+
cache_root = Path(self.spec.cache_dir)
|
|
218
|
+
cache_root.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
key = hashlib.sha256((self._fingerprint + json.dumps(materialized, sort_keys=True, default=str)).encode()).hexdigest()[:24]
|
|
220
|
+
path = cache_root / (self.spec.name + "-" + key + ".pt")
|
|
221
|
+
if path.exists():
|
|
222
|
+
payload = torch.load(str(path), map_location="cpu")
|
|
223
|
+
return payload["features"], payload["labels"]
|
|
224
|
+
x, y = self.transform(materialized)
|
|
225
|
+
fd, temp_name = tempfile.mkstemp(prefix="silver-", suffix=".pt", dir=str(cache_root))
|
|
226
|
+
os.close(fd)
|
|
227
|
+
try:
|
|
228
|
+
torch.save({"features": x, "labels": y}, temp_name)
|
|
229
|
+
os.replace(temp_name, str(path))
|
|
230
|
+
finally:
|
|
231
|
+
if os.path.exists(temp_name):
|
|
232
|
+
os.unlink(temp_name)
|
|
233
|
+
return x, y
|
|
234
|
+
|
|
235
|
+
def _transform_value(self, value: Any, column: str) -> float:
|
|
236
|
+
state = self._states[column]
|
|
237
|
+
if state.kind == "categorical":
|
|
238
|
+
category = self._as_category(value)
|
|
239
|
+
return float(state.categories.index(category) + 1) if category in state.categories else 0.0
|
|
240
|
+
number = self._number(value, column)
|
|
241
|
+
if number is None:
|
|
242
|
+
if self.spec.missing == "error":
|
|
243
|
+
raise ValueError("missing value in feature %r" % column)
|
|
244
|
+
number = state.location
|
|
245
|
+
return (number - state.location) / state.scale
|
|
246
|
+
|
|
247
|
+
def _transform_label(self, value: Any) -> Any:
|
|
248
|
+
if self.spec.label_type == "classification":
|
|
249
|
+
category = self._as_category(value)
|
|
250
|
+
if category not in self._label_mapping:
|
|
251
|
+
raise ValueError("unknown label %r after fitting" % value)
|
|
252
|
+
return self._label_mapping[category]
|
|
253
|
+
number = self._number(value, self.spec.label)
|
|
254
|
+
if number is None:
|
|
255
|
+
raise ValueError("regression label cannot be missing")
|
|
256
|
+
return number
|
|
257
|
+
|
|
258
|
+
def _impute_location(self, observed: Sequence[float]) -> float:
|
|
259
|
+
if self.spec.missing in ("mean", "unknown") and observed:
|
|
260
|
+
return sum(observed) / len(observed)
|
|
261
|
+
if self.spec.missing == "median" and observed:
|
|
262
|
+
values = sorted(observed)
|
|
263
|
+
middle = len(values) // 2
|
|
264
|
+
return values[middle] if len(values) % 2 else (values[middle - 1] + values[middle]) / 2
|
|
265
|
+
return 0.0
|
|
266
|
+
|
|
267
|
+
def _require_fit(self) -> None:
|
|
268
|
+
if not self.fitted:
|
|
269
|
+
raise RuntimeError("fit the pipeline on the training split before transform")
|
|
270
|
+
|
|
271
|
+
@staticmethod
|
|
272
|
+
def _as_category(value: Any) -> str:
|
|
273
|
+
return "<missing>" if value in (None, "") else str(value)
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def _number(value: Any, column: str) -> Optional[float]:
|
|
277
|
+
if value is None or value == "":
|
|
278
|
+
return None
|
|
279
|
+
try:
|
|
280
|
+
result = float(value)
|
|
281
|
+
except (TypeError, ValueError) as error:
|
|
282
|
+
raise ValueError("feature %r must be numeric" % column) from error
|
|
283
|
+
if not math.isfinite(result):
|
|
284
|
+
raise ValueError("feature %r must be finite" % column)
|
|
285
|
+
return result
|
|
286
|
+
|
|
287
|
+
@staticmethod
|
|
288
|
+
def _hash_rows(rows: Sequence[Mapping[str, Any]]) -> str:
|
|
289
|
+
encoded = json.dumps(rows, sort_keys=True, separators=(",", ":"), default=str)
|
|
290
|
+
return hashlib.sha256(encoded.encode()).hexdigest()[:16]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def compile_silver(source: str) -> SilverTorchPipeline:
|
|
294
|
+
return SilverTorchPipeline(parse_silver(source))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _torch() -> Any:
|
|
298
|
+
try:
|
|
299
|
+
import torch
|
|
300
|
+
except ImportError as error:
|
|
301
|
+
raise ImportError("install silver-torch[pytorch] to materialize PyTorch tensors") from error
|
|
302
|
+
return torch
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Validated, serialisable configuration for Silver's PyTorch compiler."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Dict, Tuple
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class SilverPreprocessSpec:
|
|
9
|
+
name: str
|
|
10
|
+
features: Tuple[str, ...]
|
|
11
|
+
label: str
|
|
12
|
+
architecture: str = "mlp"
|
|
13
|
+
categorical: Tuple[str, ...] = ()
|
|
14
|
+
scaling: str = "standard"
|
|
15
|
+
missing: str = "error"
|
|
16
|
+
label_type: str = "classification"
|
|
17
|
+
dtype: str = "float32"
|
|
18
|
+
batch_size: int = 64
|
|
19
|
+
shuffle: bool = True
|
|
20
|
+
num_workers: int = 0
|
|
21
|
+
prefetch_factor: int = 2
|
|
22
|
+
drop_last: bool = False
|
|
23
|
+
sequence_length: int = 1
|
|
24
|
+
seed: int = 17
|
|
25
|
+
cache_dir: str = ""
|
|
26
|
+
options: Dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
if not self.name or not self.features or not self.label:
|
|
30
|
+
raise ValueError("name, features, and label are required")
|
|
31
|
+
if len(set(self.features)) != len(self.features):
|
|
32
|
+
raise ValueError("features must not contain duplicates")
|
|
33
|
+
unknown = set(self.categorical) - set(self.features)
|
|
34
|
+
if unknown:
|
|
35
|
+
raise ValueError("categorical columns must be listed in features: %s" % sorted(unknown))
|
|
36
|
+
if self.architecture not in ("mlp", "cnn", "rnn", "transformer"):
|
|
37
|
+
raise ValueError("architecture must be mlp, cnn, rnn, or transformer")
|
|
38
|
+
if self.scaling not in ("none", "standard", "minmax"):
|
|
39
|
+
raise ValueError("scaling must be none, standard, or minmax")
|
|
40
|
+
if self.missing not in ("error", "zero", "mean", "median", "unknown"):
|
|
41
|
+
raise ValueError("missing must be error, zero, mean, median, or unknown")
|
|
42
|
+
if self.label_type not in ("classification", "regression"):
|
|
43
|
+
raise ValueError("label_type must be classification or regression")
|
|
44
|
+
if self.dtype not in ("float16", "float32", "float64", "bfloat16"):
|
|
45
|
+
raise ValueError("dtype must be a floating PyTorch dtype")
|
|
46
|
+
if self.batch_size < 1 or self.num_workers < 0 or self.prefetch_factor < 1:
|
|
47
|
+
raise ValueError("batch_size, workers, and prefetch_factor are invalid")
|
|
48
|
+
if self.sequence_length < 1 or self.seed < 0:
|
|
49
|
+
raise ValueError("sequence_length must be positive and seed non-negative")
|
|
50
|
+
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: silver-torch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Inspectable Silver dataset preprocessing and PyTorch input pipelines.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Keywords: pytorch,preprocessing,datasets,machine-learning,research
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Provides-Extra: pytorch
|
|
10
|
+
Requires-Dist: torch>=1.9.0; extra == "pytorch"
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
13
|
+
|
|
14
|
+
# silver-torch
|
|
15
|
+
|
|
16
|
+
An optional PyTorch layer for Silver. It turns a small Silver preprocessing
|
|
17
|
+
program into a fitted, inspectable, reusable tensor and `DataLoader` pipeline.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install silver-data
|
|
21
|
+
pip install 'silver-torch[pytorch]'
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from silver_data import Dataset
|
|
26
|
+
from silver_torch import compile_silver
|
|
27
|
+
|
|
28
|
+
program = """
|
|
29
|
+
pipeline ieee_inverse:
|
|
30
|
+
features voltage, current, phase, sensor
|
|
31
|
+
categorical sensor
|
|
32
|
+
label fault
|
|
33
|
+
architecture transformer
|
|
34
|
+
sequence_length 2
|
|
35
|
+
scaling standard
|
|
36
|
+
missing median
|
|
37
|
+
label_type classification
|
|
38
|
+
batch_size 128
|
|
39
|
+
num_workers 2
|
|
40
|
+
cache_dir .cache/ieee_inverse
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
dataset = Dataset.from_records("ieee", [
|
|
44
|
+
{"voltage": 1.0, "current": 2.0, "phase": 0.2, "fault": 0},
|
|
45
|
+
{"voltage": 1.2, "current": 2.1, "phase": 0.3, "fault": 1},
|
|
46
|
+
])
|
|
47
|
+
splits = dataset.split(0.8, 0.1, 0.1)
|
|
48
|
+
pipeline = compile_silver(program).fit(splits.train.records())
|
|
49
|
+
loader = pipeline.dataloader(splits.validation.records(), device="cuda")
|
|
50
|
+
print(pipeline.plan(device="cuda").to_dict())
|
|
51
|
+
print(pipeline.benchmark(splits.validation.records(), steps=20))
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The compiler has explicit research-safety boundaries:
|
|
55
|
+
|
|
56
|
+
- statistics and vocabularies are fitted only on `splits.train`;
|
|
57
|
+
- missing columns, non-finite numbers, invalid labels, and incompatible
|
|
58
|
+
sequence lengths fail loudly;
|
|
59
|
+
- categorical vocabularies are sorted for reproducibility and reserve index 0
|
|
60
|
+
for unknown values;
|
|
61
|
+
- classification targets are `torch.long`; regression targets use the chosen
|
|
62
|
+
floating dtype;
|
|
63
|
+
- cache keys include the fitted-training fingerprint and transformed rows, and
|
|
64
|
+
cache writes are atomic;
|
|
65
|
+
- loaders are seeded and tune pinning, persistent workers, prefetching, and
|
|
66
|
+
`drop_last` based on the declared runtime.
|
|
67
|
+
|
|
68
|
+
The emitted shapes are `[batch, features]` for MLP, `[batch, 1, features]` for
|
|
69
|
+
CNN, and `[batch, sequence_length, features_per_step]` for RNN/Transformer.
|
|
70
|
+
These are layout contracts, not model implementations. Measure with
|
|
71
|
+
`benchmark()` on the target machine; input speedups depend on storage, CPU,
|
|
72
|
+
worker count, batch size, and accelerator.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/silver_torch/__init__.py
|
|
4
|
+
src/silver_torch/pipeline.py
|
|
5
|
+
src/silver_torch/spec.py
|
|
6
|
+
src/silver_torch.egg-info/PKG-INFO
|
|
7
|
+
src/silver_torch.egg-info/SOURCES.txt
|
|
8
|
+
src/silver_torch.egg-info/dependency_links.txt
|
|
9
|
+
src/silver_torch.egg-info/requires.txt
|
|
10
|
+
src/silver_torch.egg-info/top_level.txt
|
|
11
|
+
tests/test_pipeline.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
silver_torch
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from silver_torch import compile_silver, parse_silver
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
PROGRAM = """
|
|
7
|
+
pipeline ieee_inverse:
|
|
8
|
+
features voltage, current
|
|
9
|
+
label fault
|
|
10
|
+
architecture transformer
|
|
11
|
+
scaling standard
|
|
12
|
+
missing median
|
|
13
|
+
batch_size 8
|
|
14
|
+
num_workers 2
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_parse_and_plan_are_inspectable():
|
|
19
|
+
spec = parse_silver(PROGRAM)
|
|
20
|
+
assert spec.name == "ieee_inverse"
|
|
21
|
+
assert spec.features == ("voltage", "current")
|
|
22
|
+
pipeline = compile_silver(PROGRAM).fit(
|
|
23
|
+
[
|
|
24
|
+
{"voltage": 1, "current": 10, "fault": 0},
|
|
25
|
+
{"voltage": 3, "current": 30, "fault": 1},
|
|
26
|
+
]
|
|
27
|
+
)
|
|
28
|
+
plan = pipeline.plan(device="cuda").to_dict()
|
|
29
|
+
assert plan["schema"] == "silver.torch/plan-2"
|
|
30
|
+
assert plan["input_shape"] == [1, 2]
|
|
31
|
+
assert plan["loader_options"]["pin_memory"] is True
|
|
32
|
+
assert plan["loader_options"]["prefetch_factor"] == 2
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_fit_uses_training_statistics_and_handles_median():
|
|
36
|
+
pipeline = compile_silver(
|
|
37
|
+
PROGRAM.replace("architecture transformer", "architecture mlp")
|
|
38
|
+
).fit(
|
|
39
|
+
[
|
|
40
|
+
{"voltage": 1, "current": 10, "fault": 0},
|
|
41
|
+
{"voltage": None, "current": 20, "fault": 1},
|
|
42
|
+
{"voltage": 3, "current": 30, "fault": 1},
|
|
43
|
+
]
|
|
44
|
+
)
|
|
45
|
+
assert pipeline.statistics["voltage"]["location"] == 2
|
|
46
|
+
assert pipeline.statistics["current"]["location"] == 20
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_tensors_are_contiguous_when_torch_is_available():
|
|
50
|
+
torch = pytest.importorskip("torch")
|
|
51
|
+
pipeline = compile_silver(PROGRAM).fit(
|
|
52
|
+
[
|
|
53
|
+
{"voltage": 1, "current": 10, "fault": 0},
|
|
54
|
+
{"voltage": 3, "current": 30, "fault": 1},
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
features, labels = pipeline.tensors(
|
|
58
|
+
[{"voltage": 2, "current": 20, "fault": 1}]
|
|
59
|
+
)
|
|
60
|
+
assert features.is_contiguous()
|
|
61
|
+
assert features.dtype == torch.float32
|
|
62
|
+
assert tuple(features.shape) == (1, 1, 2)
|
|
63
|
+
assert tuple(labels.shape) == (1,)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_missing_error_is_not_silently_imputed():
|
|
67
|
+
program = PROGRAM.replace("missing median", "missing error")
|
|
68
|
+
pipeline = compile_silver(program).fit(
|
|
69
|
+
[{"voltage": 1, "current": 10, "fault": 0}]
|
|
70
|
+
)
|
|
71
|
+
with pytest.raises(ValueError, match="missing value"):
|
|
72
|
+
pipeline.tensors([{"voltage": None, "current": 10, "fault": 0}])
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_categorical_features_and_classification_labels_are_encoded():
|
|
76
|
+
program = """
|
|
77
|
+
pipeline categorical:
|
|
78
|
+
features sensor, voltage
|
|
79
|
+
categorical sensor
|
|
80
|
+
label fault
|
|
81
|
+
scaling none
|
|
82
|
+
missing unknown
|
|
83
|
+
"""
|
|
84
|
+
torch = pytest.importorskip("torch")
|
|
85
|
+
pipeline = compile_silver(program).fit([
|
|
86
|
+
{"sensor": "a", "voltage": 1, "fault": "healthy"},
|
|
87
|
+
{"sensor": "b", "voltage": 2, "fault": "fault"},
|
|
88
|
+
])
|
|
89
|
+
features, labels = pipeline.transform([
|
|
90
|
+
{"sensor": "unseen", "voltage": 3, "fault": "fault"}
|
|
91
|
+
])
|
|
92
|
+
assert features[0, 0].item() == 0.0
|
|
93
|
+
assert labels.dtype == torch.long
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_regression_and_sequence_layout():
|
|
97
|
+
program = """
|
|
98
|
+
pipeline sequence:
|
|
99
|
+
features x1, x2, x3, x4
|
|
100
|
+
label target
|
|
101
|
+
architecture rnn
|
|
102
|
+
label_type regression
|
|
103
|
+
sequence_length 2
|
|
104
|
+
"""
|
|
105
|
+
torch = pytest.importorskip("torch")
|
|
106
|
+
pipeline = compile_silver(program).fit([
|
|
107
|
+
{"x1": 1, "x2": 2, "x3": 3, "x4": 4, "target": 0.5}
|
|
108
|
+
])
|
|
109
|
+
features, labels = pipeline.transform([
|
|
110
|
+
{"x1": 1, "x2": 2, "x3": 3, "x4": 4, "target": 0.5}
|
|
111
|
+
])
|
|
112
|
+
assert tuple(features.shape) == (1, 2, 2)
|
|
113
|
+
assert labels.dtype == torch.float32
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_invalid_sequence_layout_fails_before_materialization():
|
|
117
|
+
program = PROGRAM.replace("architecture transformer", "architecture transformer").replace(
|
|
118
|
+
"features voltage, current", "features voltage, current, phase"
|
|
119
|
+
) + " sequence_length 2\n"
|
|
120
|
+
pipeline = compile_silver(program).fit([
|
|
121
|
+
{"voltage": 1, "current": 10, "phase": 2, "fault": 0},
|
|
122
|
+
{"voltage": 3, "current": 30, "phase": 4, "fault": 1},
|
|
123
|
+
])
|
|
124
|
+
with pytest.raises(ValueError, match="feature count"):
|
|
125
|
+
pipeline.plan()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_cache_and_benchmark(tmp_path):
|
|
129
|
+
pytest.importorskip("torch")
|
|
130
|
+
program = PROGRAM.replace("num_workers 2", "num_workers 0") + " cache_dir %s\n" % tmp_path
|
|
131
|
+
pipeline = compile_silver(program).fit([
|
|
132
|
+
{"voltage": 1, "current": 10, "fault": 0},
|
|
133
|
+
{"voltage": 3, "current": 30, "fault": 1},
|
|
134
|
+
])
|
|
135
|
+
rows = [{"voltage": 2, "current": 20, "fault": 1}]
|
|
136
|
+
first = pipeline.dataloader(rows)
|
|
137
|
+
second = pipeline.dataloader(rows)
|
|
138
|
+
assert len(list(first)) == len(list(second)) == 1
|
|
139
|
+
result = pipeline.benchmark(rows, steps=1)
|
|
140
|
+
assert result["samples"] == 1.0
|
|
141
|
+
assert result["samples_per_second"] > 0
|