CPSBench 0.1.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.
- cpsbench/__init__.py +76 -0
- cpsbench/__main__.py +38 -0
- cpsbench/datasets.py +130 -0
- cpsbench/download.py +160 -0
- cpsbench/preprocess.py +158 -0
- cpsbench/py.typed +1 -0
- cpsbench/specs.py +179 -0
- cpsbench/v2x.py +142 -0
- cpsbench/windows.py +111 -0
- cpsbench-0.1.0.dist-info/METADATA +110 -0
- cpsbench-0.1.0.dist-info/RECORD +14 -0
- cpsbench-0.1.0.dist-info/WHEEL +5 -0
- cpsbench-0.1.0.dist-info/licenses/LICENSE +21 -0
- cpsbench-0.1.0.dist-info/top_level.txt +1 -0
cpsbench/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""CPSBench datasets, loaded like MNIST.
|
|
2
|
+
|
|
3
|
+
Example::
|
|
4
|
+
|
|
5
|
+
from cpsbench import SynCAN
|
|
6
|
+
from torch.utils.data import DataLoader
|
|
7
|
+
|
|
8
|
+
train = SynCAN(root="./data", split="train", download=True)
|
|
9
|
+
window, label = train[0]
|
|
10
|
+
loader = DataLoader(train, batch_size=64, shuffle=True)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from .datasets import MisbehaviorX, ROAD, SynCAN, VehicularDataset
|
|
18
|
+
from .specs import DatasetSpec, get_spec, list_specs
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"SynCAN",
|
|
22
|
+
"ROAD",
|
|
23
|
+
"MisbehaviorX",
|
|
24
|
+
"VehicularDataset",
|
|
25
|
+
"load",
|
|
26
|
+
"list_datasets",
|
|
27
|
+
"describe",
|
|
28
|
+
"get_spec",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|
|
32
|
+
|
|
33
|
+
_CLASSES = {
|
|
34
|
+
"syncan": SynCAN,
|
|
35
|
+
"road": ROAD,
|
|
36
|
+
"misbehaviorx": MisbehaviorX,
|
|
37
|
+
"vasp": MisbehaviorX,
|
|
38
|
+
"veremi": MisbehaviorX,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def list_datasets(status: Optional[str] = None) -> list[dict]:
|
|
43
|
+
"""Return name, status, family, and shape for every registered dataset."""
|
|
44
|
+
rows = []
|
|
45
|
+
for spec in list_specs(status):
|
|
46
|
+
rows.append(
|
|
47
|
+
{
|
|
48
|
+
"name": spec.name,
|
|
49
|
+
"status": spec.status,
|
|
50
|
+
"family": spec.family,
|
|
51
|
+
"input_shape": spec.input_shape if spec.features else None,
|
|
52
|
+
"downloadable": spec.downloadable,
|
|
53
|
+
"description": spec.description,
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
return rows
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def describe(name: str) -> DatasetSpec:
|
|
60
|
+
return get_spec(name)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load(name: str, **kwargs) -> VehicularDataset:
|
|
64
|
+
"""Load a dataset by name.
|
|
65
|
+
|
|
66
|
+
``kwargs`` are forwarded to the dataset class (``root``, ``split``,
|
|
67
|
+
``download``, ``window_size``, ...).
|
|
68
|
+
"""
|
|
69
|
+
key = name.strip().lower()
|
|
70
|
+
if key == "x-canids":
|
|
71
|
+
spec = get_spec(key)
|
|
72
|
+
raise NotImplementedError(f"{spec.name} is not implemented yet. {spec.notes}")
|
|
73
|
+
if key not in _CLASSES:
|
|
74
|
+
known = ", ".join(sorted(set(_CLASSES) | {"x-canids"}))
|
|
75
|
+
raise KeyError(f"Unknown dataset '{name}'. Known datasets: {known}")
|
|
76
|
+
return _CLASSES[key](**kwargs)
|
cpsbench/__main__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Small command-line helper: ``python -m cpsbench list``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from . import describe, list_datasets, load
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
parser = argparse.ArgumentParser(description="Download and inspect vehicular IDS datasets.")
|
|
12
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
13
|
+
|
|
14
|
+
sub.add_parser("list", help="List registered datasets")
|
|
15
|
+
|
|
16
|
+
info = sub.add_parser("info", help="Show one dataset specification")
|
|
17
|
+
info.add_argument("name")
|
|
18
|
+
|
|
19
|
+
fetch = sub.add_parser("download", help="Download and preprocess a split")
|
|
20
|
+
fetch.add_argument("name")
|
|
21
|
+
fetch.add_argument("--root", default="./data")
|
|
22
|
+
fetch.add_argument("--split", default="train", choices=["train", "test"])
|
|
23
|
+
|
|
24
|
+
args = parser.parse_args()
|
|
25
|
+
if args.command == "list":
|
|
26
|
+
for row in list_datasets():
|
|
27
|
+
shape = row["input_shape"] or "n/a"
|
|
28
|
+
print(f"{row['name']:16} {row['status']:8} {row['family']:4} {shape}")
|
|
29
|
+
elif args.command == "info":
|
|
30
|
+
spec = describe(args.name)
|
|
31
|
+
print(spec)
|
|
32
|
+
else:
|
|
33
|
+
dataset = load(args.name, root=args.root, split=args.split, download=True, verbose=True)
|
|
34
|
+
print(f"Loaded {len(dataset)} windows with shape {dataset.input_shape}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
main()
|
cpsbench/datasets.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Public dataset classes.
|
|
2
|
+
|
|
3
|
+
Usage matches torchvision:
|
|
4
|
+
|
|
5
|
+
from cpsbench import SynCAN
|
|
6
|
+
|
|
7
|
+
train = SynCAN(root="./data", split="train", download=True)
|
|
8
|
+
window, label = train[0]
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from torch.utils.data import Dataset
|
|
17
|
+
|
|
18
|
+
from .download import dataset_root, ensure_downloaded, split_dir
|
|
19
|
+
from .preprocess import prepare_can_split
|
|
20
|
+
from .specs import DatasetSpec, get_spec
|
|
21
|
+
from .v2x import V2XWindowDataset, prepare_v2x_split
|
|
22
|
+
from .windows import WindowDataset
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class VehicularDataset(Dataset):
|
|
26
|
+
"""Base class for datasets that download, preprocess, and yield windows."""
|
|
27
|
+
|
|
28
|
+
spec_name: str = ""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
root: str | Path = "./data",
|
|
33
|
+
split: str = "train",
|
|
34
|
+
download: bool = True,
|
|
35
|
+
transform=None,
|
|
36
|
+
target_transform=None,
|
|
37
|
+
window_size: Optional[int] = None,
|
|
38
|
+
step_size: Optional[int] = None,
|
|
39
|
+
sampling_period: Optional[int] = None,
|
|
40
|
+
return_meta: bool = False,
|
|
41
|
+
data_dir: Optional[str | Path] = None,
|
|
42
|
+
scaler_dir: Optional[str | Path] = None,
|
|
43
|
+
n_jobs: Optional[int] = None,
|
|
44
|
+
verbose: bool = False,
|
|
45
|
+
) -> None:
|
|
46
|
+
spec = get_spec(self.spec_name).with_overrides(window_size, step_size, sampling_period)
|
|
47
|
+
if spec.status == "planned":
|
|
48
|
+
raise NotImplementedError(f"{spec.name} is not implemented yet. {spec.notes}")
|
|
49
|
+
|
|
50
|
+
self.spec = spec
|
|
51
|
+
self.root = Path(root).expanduser().resolve()
|
|
52
|
+
self.split = split
|
|
53
|
+
self.transform = transform
|
|
54
|
+
self.target_transform = target_transform
|
|
55
|
+
self.return_meta = return_meta
|
|
56
|
+
|
|
57
|
+
if data_dir is None and download:
|
|
58
|
+
ensure_downloaded(spec, self.root)
|
|
59
|
+
|
|
60
|
+
self.data_dir = Path(data_dir) if data_dir is not None else split_dir(self.root, spec.name, split)
|
|
61
|
+
if scaler_dir is not None:
|
|
62
|
+
self.scaler_path = Path(scaler_dir) / f"min_max_values_{spec.name}.csv"
|
|
63
|
+
else:
|
|
64
|
+
self.scaler_path = dataset_root(self.root, spec.name) / "scaler" / f"min_max_values_{spec.name}.csv"
|
|
65
|
+
|
|
66
|
+
fit_scaler = split.strip().lower() in {"train", "training", "ambient"}
|
|
67
|
+
if spec.family == "can":
|
|
68
|
+
prepare_can_split(spec, self.data_dir, self.scaler_path, fit_scaler, n_jobs=n_jobs)
|
|
69
|
+
self._base = WindowDataset(spec, self.data_dir, self.scaler_path, return_meta, verbose)
|
|
70
|
+
elif spec.family == "v2x":
|
|
71
|
+
prepare_v2x_split(spec, self.data_dir, self.scaler_path, fit_scaler)
|
|
72
|
+
self._base = V2XWindowDataset(spec, self.data_dir, self.scaler_path, return_meta, verbose)
|
|
73
|
+
else:
|
|
74
|
+
raise ValueError(f"Unsupported dataset family: {spec.family}")
|
|
75
|
+
|
|
76
|
+
def __len__(self) -> int:
|
|
77
|
+
return len(self._base)
|
|
78
|
+
|
|
79
|
+
def __getitem__(self, idx: int):
|
|
80
|
+
item = self._base[idx]
|
|
81
|
+
window, label = item[0], item[1]
|
|
82
|
+
if self.transform is not None:
|
|
83
|
+
window = self.transform(window)
|
|
84
|
+
if self.target_transform is not None:
|
|
85
|
+
label = self.target_transform(label)
|
|
86
|
+
if self.return_meta:
|
|
87
|
+
return window, label, item[2]
|
|
88
|
+
return window, label
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def classes(self) -> tuple[str, ...]:
|
|
92
|
+
return self.spec.classes
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def input_shape(self) -> tuple[int, int, int]:
|
|
96
|
+
return self.spec.input_shape
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def num_signals(self) -> int:
|
|
100
|
+
return self.spec.num_signals
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def window_size(self) -> int:
|
|
104
|
+
return self.spec.window_size
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def channels(self) -> int:
|
|
108
|
+
return self.spec.channels
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def features(self) -> tuple[str, ...]:
|
|
112
|
+
return self.spec.features
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class SynCAN(VehicularDataset):
|
|
116
|
+
"""SynCAN intrusion dataset. ``split`` is ``'train'`` or ``'test'``."""
|
|
117
|
+
|
|
118
|
+
spec_name = "syncan"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ROAD(VehicularDataset):
|
|
122
|
+
"""ROAD CAN intrusion dataset. ``split`` is ``'train'`` or ``'test'``."""
|
|
123
|
+
|
|
124
|
+
spec_name = "road"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class MisbehaviorX(VehicularDataset):
|
|
128
|
+
"""V2X misbehavior dataset. Requires a local copy; download is manual."""
|
|
129
|
+
|
|
130
|
+
spec_name = "misbehaviorx"
|
cpsbench/download.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Download raw vehicular datasets into a local root directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import urllib.request
|
|
8
|
+
import zipfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .specs import DatasetSpec
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DownloadError(RuntimeError):
|
|
15
|
+
"""Raised when a dataset cannot be fetched or unpacked."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _require_tool(name: str) -> None:
|
|
19
|
+
if shutil.which(name) is None:
|
|
20
|
+
raise DownloadError(
|
|
21
|
+
f"'{name}' is required to download this dataset but was not found on PATH."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _download_file(url: str, destination: Path) -> None:
|
|
26
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
print(f"Downloading {url}")
|
|
28
|
+
try:
|
|
29
|
+
urllib.request.urlretrieve(url, destination)
|
|
30
|
+
except Exception as exc:
|
|
31
|
+
raise DownloadError(f"Failed to download {url}: {exc}") from exc
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def split_dirname(split: str) -> str:
|
|
35
|
+
key = split.strip().lower()
|
|
36
|
+
mapping = {
|
|
37
|
+
"train": "ambient",
|
|
38
|
+
"training": "ambient",
|
|
39
|
+
"ambient": "ambient",
|
|
40
|
+
"test": "attacks",
|
|
41
|
+
"testing": "attacks",
|
|
42
|
+
"attack": "attacks",
|
|
43
|
+
"attacks": "attacks",
|
|
44
|
+
}
|
|
45
|
+
if key not in mapping:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"Unsupported split '{split}'. Use 'train'/'ambient' or 'test'/'attacks'."
|
|
48
|
+
)
|
|
49
|
+
return mapping[key]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def dataset_root(root: str | Path, name: str) -> Path:
|
|
53
|
+
return Path(root).expanduser().resolve() / name
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def split_dir(root: str | Path, name: str, split: str) -> Path:
|
|
57
|
+
return dataset_root(root, name) / split_dirname(split)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def raw_csvs_present(path: Path) -> bool:
|
|
61
|
+
return path.is_dir() and any(path.glob("*.csv"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def download_syncan(destination: Path) -> None:
|
|
65
|
+
"""Clone SynCAN and unpack ambient/attacks next to each other."""
|
|
66
|
+
_require_tool("git")
|
|
67
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
if raw_csvs_present(destination / "ambient") and raw_csvs_present(destination / "attacks"):
|
|
69
|
+
print(f"SynCAN already present at {destination}")
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
clone_dir = destination / "_raw"
|
|
73
|
+
if clone_dir.exists():
|
|
74
|
+
shutil.rmtree(clone_dir)
|
|
75
|
+
print(f"Cloning SynCAN into {clone_dir}")
|
|
76
|
+
subprocess.run(
|
|
77
|
+
["git", "clone", "--depth", "1", "https://github.com/etas/SynCAN.git", str(clone_dir)],
|
|
78
|
+
check=True,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
for pattern, folder in (("train_*.zip", "ambient"), ("test_*.zip", "attacks")):
|
|
82
|
+
zips = sorted(clone_dir.glob(pattern))
|
|
83
|
+
if not zips:
|
|
84
|
+
raise DownloadError(f"SynCAN clone is missing {pattern} archives.")
|
|
85
|
+
out = destination / folder
|
|
86
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
for archive in zips:
|
|
88
|
+
print(f"Extracting {archive.name} -> {out}")
|
|
89
|
+
with zipfile.ZipFile(archive) as zf:
|
|
90
|
+
zf.extractall(out)
|
|
91
|
+
|
|
92
|
+
for normal in (destination / "attacks").glob("test_normal*"):
|
|
93
|
+
if normal.is_file():
|
|
94
|
+
normal.unlink()
|
|
95
|
+
shutil.rmtree(clone_dir, ignore_errors=True)
|
|
96
|
+
print(f"SynCAN downloaded to {destination}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def download_road(destination: Path) -> None:
|
|
100
|
+
"""Download the ROAD signal-extraction release from Zenodo."""
|
|
101
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
if raw_csvs_present(destination / "ambient") and raw_csvs_present(destination / "attacks"):
|
|
103
|
+
print(f"ROAD already present at {destination}")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
archive = destination / "road.zip"
|
|
107
|
+
_download_file("https://zenodo.org/records/10462796/files/road.zip", archive)
|
|
108
|
+
extract_root = destination / "_raw"
|
|
109
|
+
if extract_root.exists():
|
|
110
|
+
shutil.rmtree(extract_root)
|
|
111
|
+
extract_root.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
print(f"Extracting {archive.name}")
|
|
113
|
+
with zipfile.ZipFile(archive) as zf:
|
|
114
|
+
zf.extractall(extract_root)
|
|
115
|
+
|
|
116
|
+
signal_dir = next(extract_root.rglob("signal_extractions"), None)
|
|
117
|
+
source = signal_dir if signal_dir is not None else extract_root
|
|
118
|
+
for folder in ("ambient", "attacks"):
|
|
119
|
+
matches = [path for path in source.rglob(folder) if path.is_dir()]
|
|
120
|
+
if not matches:
|
|
121
|
+
raise DownloadError(
|
|
122
|
+
f"ROAD archive does not contain an '{folder}' directory. "
|
|
123
|
+
f"Inspect {extract_root} and place the CSVs manually."
|
|
124
|
+
)
|
|
125
|
+
target = destination / folder
|
|
126
|
+
if target.exists():
|
|
127
|
+
shutil.rmtree(target)
|
|
128
|
+
shutil.move(str(matches[0]), str(target))
|
|
129
|
+
|
|
130
|
+
# Original release includes non-ambient files inside ambient/.
|
|
131
|
+
for extra in (destination / "ambient").iterdir():
|
|
132
|
+
if extra.is_file() and not extra.name.startswith("ambient_"):
|
|
133
|
+
extra.unlink()
|
|
134
|
+
|
|
135
|
+
archive.unlink(missing_ok=True)
|
|
136
|
+
shutil.rmtree(extract_root, ignore_errors=True)
|
|
137
|
+
print(f"ROAD downloaded to {destination}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def ensure_downloaded(spec: DatasetSpec, root: str | Path) -> Path:
|
|
141
|
+
"""Download ``spec`` under ``root/<name>`` and return that directory."""
|
|
142
|
+
destination = dataset_root(root, spec.name)
|
|
143
|
+
if spec.status == "planned":
|
|
144
|
+
raise DownloadError(
|
|
145
|
+
f"{spec.name} is registered but not implemented yet. {spec.notes}"
|
|
146
|
+
)
|
|
147
|
+
if not spec.downloadable:
|
|
148
|
+
if raw_csvs_present(destination / "ambient") or raw_csvs_present(destination / "attacks"):
|
|
149
|
+
return destination
|
|
150
|
+
raise DownloadError(
|
|
151
|
+
f"{spec.name} cannot be downloaded automatically. {spec.notes} "
|
|
152
|
+
f"Expected files under {destination}."
|
|
153
|
+
)
|
|
154
|
+
if spec.name == "syncan":
|
|
155
|
+
download_syncan(destination)
|
|
156
|
+
elif spec.name == "road":
|
|
157
|
+
download_road(destination)
|
|
158
|
+
else:
|
|
159
|
+
raise DownloadError(f"No downloader registered for {spec.name}.")
|
|
160
|
+
return destination
|
cpsbench/preprocess.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Turn raw CAN CSVs into window-ready signal and attribute arrays."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import glob
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from joblib import Parallel, delayed
|
|
13
|
+
from tqdm import tqdm
|
|
14
|
+
|
|
15
|
+
from .specs import DatasetSpec
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _process_id(target_id, frame: pd.DataFrame) -> pd.DataFrame:
|
|
19
|
+
per_id = frame[frame["ID"] == target_id].T.dropna().T
|
|
20
|
+
rename = {}
|
|
21
|
+
skip = {"ID", "Label", "Time"}
|
|
22
|
+
for column in set(per_id.columns) - skip:
|
|
23
|
+
cleaned = column.replace("Signal_", "Signal").replace("_of_ID", "")
|
|
24
|
+
rename[column] = f"ID_{target_id}_" + cleaned.replace("Signal", "Sig_")
|
|
25
|
+
renamed = per_id.rename(columns=rename)
|
|
26
|
+
keep = [name for name in rename.values() if name in renamed.columns]
|
|
27
|
+
return renamed[keep]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _update_minmax(spec: DatasetSpec, signals: pd.DataFrame, scaler_path: Path) -> None:
|
|
31
|
+
features = list(spec.features)
|
|
32
|
+
present = [name for name in features if name in signals.columns]
|
|
33
|
+
if not present:
|
|
34
|
+
return
|
|
35
|
+
try:
|
|
36
|
+
stored = pd.read_csv(scaler_path, index_col=0)
|
|
37
|
+
overall_min = stored["Min"].reindex(features)
|
|
38
|
+
overall_max = stored["Max"].reindex(features)
|
|
39
|
+
except (FileNotFoundError, KeyError, pd.errors.EmptyDataError):
|
|
40
|
+
scaler_path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
overall_min = pd.Series(np.nan, index=features)
|
|
42
|
+
overall_max = pd.Series(np.nan, index=features)
|
|
43
|
+
|
|
44
|
+
current_min = signals[present].min(axis=0).to_numpy()
|
|
45
|
+
current_max = signals[present].max(axis=0).to_numpy()
|
|
46
|
+
stored_min = overall_min.loc[present].to_numpy(dtype=float)
|
|
47
|
+
stored_max = overall_max.loc[present].to_numpy(dtype=float)
|
|
48
|
+
overall_min.loc[present] = np.where(np.isnan(stored_min), current_min, np.minimum(stored_min, current_min))
|
|
49
|
+
overall_max.loc[present] = np.where(np.isnan(stored_max), current_max, np.maximum(stored_max, current_max))
|
|
50
|
+
pd.DataFrame({"Min": overall_min, "Max": overall_max}, index=features).to_csv(scaler_path)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _sparse_one_file(
|
|
54
|
+
spec: DatasetSpec,
|
|
55
|
+
file_name: str,
|
|
56
|
+
file_path: Path,
|
|
57
|
+
scaler_path: Path,
|
|
58
|
+
fit_scaler: bool,
|
|
59
|
+
n_jobs: int,
|
|
60
|
+
) -> None:
|
|
61
|
+
frame = pd.read_csv(file_path, skiprows=1, names=list(spec.org_features))
|
|
62
|
+
frame["ID"] = frame["ID"].astype(str).str.replace("id", "", regex=False)
|
|
63
|
+
|
|
64
|
+
total_elements = 75_000_000
|
|
65
|
+
max_length = max(int(total_elements / max(len(spec.org_features), 1)), 1)
|
|
66
|
+
n_chunks = int(np.ceil(len(frame) / max_length))
|
|
67
|
+
chunk_length = int(np.ceil(len(frame) / max(n_chunks, 1)))
|
|
68
|
+
|
|
69
|
+
for index in range(n_chunks):
|
|
70
|
+
chunk = frame.iloc[index * chunk_length : min((index + 1) * chunk_length, len(frame))]
|
|
71
|
+
pieces = Parallel(n_jobs=n_jobs)(
|
|
72
|
+
delayed(_process_id)(target_id, chunk)
|
|
73
|
+
for target_id in tqdm(chunk["ID"].unique(), desc=file_name, leave=False)
|
|
74
|
+
)
|
|
75
|
+
extended = pd.concat(pieces, axis=1)
|
|
76
|
+
extended.insert(0, "File", file_name)
|
|
77
|
+
extended[["ID", "Label", "Time"]] = chunk[["ID", "Label", "Time"]]
|
|
78
|
+
extended = extended.sort_index()
|
|
79
|
+
|
|
80
|
+
out = file_path.parent / "generated" / f"gen_{file_name}_{index + 1}.parquet"
|
|
81
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
extended.to_parquet(out, engine="pyarrow", compression="snappy")
|
|
83
|
+
if fit_scaler:
|
|
84
|
+
_update_minmax(spec, extended, scaler_path)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _write_file_index(split_path: Path) -> dict[str, int]:
|
|
88
|
+
csvs = sorted(glob.glob(str(split_path / "*.csv")))
|
|
89
|
+
mapping = {Path(path).stem: index for index, path in enumerate(csvs)}
|
|
90
|
+
with open(split_path / "file_index_dict.json", "w", encoding="utf-8") as handle:
|
|
91
|
+
json.dump(mapping, handle, indent=2)
|
|
92
|
+
return mapping
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _npy_one_file(
|
|
96
|
+
spec: DatasetSpec,
|
|
97
|
+
file_name: str,
|
|
98
|
+
parquet_path: Path,
|
|
99
|
+
file_enum: dict[str, int],
|
|
100
|
+
) -> None:
|
|
101
|
+
frame = pd.read_parquet(parquet_path, engine="pyarrow")
|
|
102
|
+
frame = frame.replace(file_enum)
|
|
103
|
+
signals = frame[list(spec.features)]
|
|
104
|
+
attributes = frame[list(spec.attributes)].copy()
|
|
105
|
+
attributes["ID"] = attributes["ID"].astype(int)
|
|
106
|
+
|
|
107
|
+
total_elements = 100_000_000
|
|
108
|
+
max_length = max(int(total_elements / max(len(spec.features), 1)), 1)
|
|
109
|
+
n_chunks = int(np.ceil(len(signals) / max_length))
|
|
110
|
+
generated = parquet_path.parent
|
|
111
|
+
|
|
112
|
+
for index in range(n_chunks):
|
|
113
|
+
start = int(index * max_length)
|
|
114
|
+
stop = min(int((index + 1) * max_length), len(signals))
|
|
115
|
+
signal_chunk = signals.iloc[start:stop]
|
|
116
|
+
attr_chunk = attributes.iloc[start:stop]
|
|
117
|
+
if spec.filling == "forward":
|
|
118
|
+
signal_chunk = signal_chunk.ffill().bfill()
|
|
119
|
+
np.save(generated / f"sig_{file_name}_{index + 1}.npy", signal_chunk.to_numpy())
|
|
120
|
+
np.save(generated / f"att_{file_name}_{index + 1}.npy", attr_chunk.to_numpy())
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def prepare_can_split(
|
|
124
|
+
spec: DatasetSpec,
|
|
125
|
+
split_path: Path,
|
|
126
|
+
scaler_path: Path,
|
|
127
|
+
fit_scaler: bool,
|
|
128
|
+
n_jobs: int | None = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Download-side preprocessing for one SynCAN/ROAD split directory."""
|
|
131
|
+
split_path = Path(split_path)
|
|
132
|
+
if not any(split_path.glob("*.csv")):
|
|
133
|
+
raise FileNotFoundError(
|
|
134
|
+
f"No raw CSV files in {split_path}. Download the dataset or point root at an existing copy."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
generated = split_path / "generated"
|
|
138
|
+
generated.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
workers = n_jobs if n_jobs is not None else min(8, os.cpu_count() or 1)
|
|
140
|
+
|
|
141
|
+
if not list(generated.glob("gen_*.parquet")):
|
|
142
|
+
for csv_path in sorted(split_path.glob("*.csv")):
|
|
143
|
+
_sparse_one_file(spec, csv_path.stem, csv_path, scaler_path, fit_scaler, workers)
|
|
144
|
+
elif fit_scaler and not scaler_path.exists():
|
|
145
|
+
for parquet_path in sorted(generated.glob("gen_*.parquet")):
|
|
146
|
+
frame = pd.read_parquet(parquet_path, engine="pyarrow", columns=list(spec.features))
|
|
147
|
+
_update_minmax(spec, frame, scaler_path)
|
|
148
|
+
|
|
149
|
+
if not scaler_path.exists():
|
|
150
|
+
raise FileNotFoundError(
|
|
151
|
+
f"Missing scaler {scaler_path}. Prepare the train/ambient split first so min/max can be fit."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
file_enum = _write_file_index(split_path)
|
|
155
|
+
for parquet_path in sorted(generated.glob("gen_*.parquet")):
|
|
156
|
+
npy_files = list(generated.glob(f"sig_{parquet_path.stem}_*.npy"))
|
|
157
|
+
if not npy_files:
|
|
158
|
+
_npy_one_file(spec, parquet_path.stem, parquet_path, file_enum)
|
cpsbench/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
cpsbench/specs.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Built-in dataset specifications.
|
|
2
|
+
|
|
3
|
+
These replace per-experiment YAML so a caller can load a dataset the same way
|
|
4
|
+
torchvision loads MNIST: name, root, split, download.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class DatasetSpec:
|
|
15
|
+
"""Static description of one vehicular cybersecurity dataset."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
family: str # "can" or "v2x"
|
|
19
|
+
description: str
|
|
20
|
+
citation: str
|
|
21
|
+
source_url: str
|
|
22
|
+
downloadable: bool
|
|
23
|
+
features: tuple[str, ...]
|
|
24
|
+
attributes: tuple[str, ...]
|
|
25
|
+
org_features: tuple[str, ...] = ()
|
|
26
|
+
window_size: int = 50
|
|
27
|
+
step_size: int = 10
|
|
28
|
+
sampling_period: int = 1
|
|
29
|
+
sampling_period_factors: tuple[int, ...] = ()
|
|
30
|
+
filling: str = "forward"
|
|
31
|
+
channels: int = 1
|
|
32
|
+
label_index: int = 2
|
|
33
|
+
classes: tuple[str, ...] = ("benign", "attack")
|
|
34
|
+
status: str = "ready" # ready | manual | planned
|
|
35
|
+
notes: str = ""
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def num_signals(self) -> int:
|
|
39
|
+
return len(self.features)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def input_shape(self) -> tuple[int, int, int]:
|
|
43
|
+
"""Channel-first window shape, matching a 1-channel image."""
|
|
44
|
+
return (self.channels, self.window_size, self.num_signals)
|
|
45
|
+
|
|
46
|
+
def with_overrides(
|
|
47
|
+
self,
|
|
48
|
+
window_size: Optional[int] = None,
|
|
49
|
+
step_size: Optional[int] = None,
|
|
50
|
+
sampling_period: Optional[int] = None,
|
|
51
|
+
) -> "DatasetSpec":
|
|
52
|
+
updates = {}
|
|
53
|
+
if window_size is not None:
|
|
54
|
+
updates["window_size"] = int(window_size)
|
|
55
|
+
if step_size is not None:
|
|
56
|
+
updates["step_size"] = int(step_size)
|
|
57
|
+
if sampling_period is not None:
|
|
58
|
+
updates["sampling_period"] = int(sampling_period)
|
|
59
|
+
return replace(self, **updates) if updates else self
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
SYNCAN_FEATURES = (
|
|
63
|
+
"ID_2_Sig_1", "ID_7_Sig_1", "ID_3_Sig_2", "ID_10_Sig_1", "ID_9_Sig_1",
|
|
64
|
+
"ID_1_Sig_1", "ID_10_Sig_4", "ID_2_Sig_2", "ID_10_Sig_3", "ID_6_Sig_1",
|
|
65
|
+
"ID_5_Sig_2", "ID_4_Sig_1", "ID_5_Sig_1", "ID_2_Sig_3", "ID_8_Sig_1",
|
|
66
|
+
"ID_6_Sig_2", "ID_10_Sig_2", "ID_7_Sig_2", "ID_1_Sig_2", "ID_3_Sig_1",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# ID-2,6,9 -> 15/5, ID-4,10 -> 25/10, ID-1,3,5,7,8 -> 5/1
|
|
70
|
+
SYNCAN_PERIOD_FACTORS = (5, 1, 1, 10, 5, 1, 10, 5, 10, 5, 1, 10, 1, 5, 1, 5, 10, 1, 1, 1)
|
|
71
|
+
|
|
72
|
+
ROAD_FEATURES = (
|
|
73
|
+
"ID_1413_Sig_7", "ID_930_Sig_5", "ID_1621_Sig_6", "ID_186_Sig_7", "ID_692_Sig_2",
|
|
74
|
+
"ID_1628_Sig_4", "ID_1255_Sig_2", "ID_1668_Sig_5", "ID_1760_Sig_4", "ID_1760_Sig_3",
|
|
75
|
+
"ID_208_Sig_6", "ID_1760_Sig_2", "ID_1760_Sig_1", "ID_526_Sig_2", "ID_1176_Sig_4",
|
|
76
|
+
"ID_167_Sig_6", "ID_208_Sig_3", "ID_1455_Sig_14", "ID_661_Sig_1", "ID_192_Sig_1",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
CAN_ORG_FEATURES = (
|
|
80
|
+
"Label", "Time", "ID",
|
|
81
|
+
"Signal1_of_ID", "Signal2_of_ID", "Signal3_of_ID", "Signal4_of_ID",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
ROAD_ORG_FEATURES = CAN_ORG_FEATURES + tuple(
|
|
85
|
+
f"Signal{i}_of_ID" for i in range(5, 23)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
CAN_ATTRIBUTES = ("Time", "ID", "Label", "File")
|
|
89
|
+
|
|
90
|
+
MISBEHAVIORX_FEATURES = (
|
|
91
|
+
"speed_x", "del_pos_x", "speed_y", "del_pos_y",
|
|
92
|
+
"accel_x", "del_speed_x", "accel_y", "del_speed_y",
|
|
93
|
+
"del_heading_x", "yaw_rate_x", "del_heading_y", "yaw_rate_y",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
MISBEHAVIORX_ATTRIBUTES = ("id", "time_chunk", "attack_gt", "attack_name")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
SPECS: dict[str, DatasetSpec] = {
|
|
100
|
+
"syncan": DatasetSpec(
|
|
101
|
+
name="syncan",
|
|
102
|
+
family="can",
|
|
103
|
+
description="Synthetic CAN dataset with signal-level intrusion traces.",
|
|
104
|
+
citation="Hanselmann et al., SynCAN, ETAS.",
|
|
105
|
+
source_url="https://github.com/etas/SynCAN",
|
|
106
|
+
downloadable=True,
|
|
107
|
+
features=SYNCAN_FEATURES,
|
|
108
|
+
attributes=CAN_ATTRIBUTES,
|
|
109
|
+
org_features=CAN_ORG_FEATURES,
|
|
110
|
+
window_size=50,
|
|
111
|
+
step_size=10,
|
|
112
|
+
sampling_period_factors=SYNCAN_PERIOD_FACTORS,
|
|
113
|
+
),
|
|
114
|
+
"road": DatasetSpec(
|
|
115
|
+
name="road",
|
|
116
|
+
family="can",
|
|
117
|
+
description="Real ORNL Automotive Dynamometer (ROAD) CAN intrusion dataset.",
|
|
118
|
+
citation="Verma et al., ROAD, ORNL.",
|
|
119
|
+
source_url="https://zenodo.org/records/10462796",
|
|
120
|
+
downloadable=True,
|
|
121
|
+
features=ROAD_FEATURES,
|
|
122
|
+
attributes=CAN_ATTRIBUTES,
|
|
123
|
+
org_features=ROAD_ORG_FEATURES,
|
|
124
|
+
window_size=50,
|
|
125
|
+
step_size=5,
|
|
126
|
+
sampling_period_factors=tuple(1 for _ in ROAD_FEATURES),
|
|
127
|
+
),
|
|
128
|
+
"misbehaviorx": DatasetSpec(
|
|
129
|
+
name="misbehaviorx",
|
|
130
|
+
family="v2x",
|
|
131
|
+
description="VeReMi-extension / MisbehaviorX V2X misbehavior traces.",
|
|
132
|
+
citation="Kamel et al., VeReMi Extension.",
|
|
133
|
+
source_url="https://github.com/josephkamel/VeReMi-Dataset",
|
|
134
|
+
downloadable=False,
|
|
135
|
+
features=MISBEHAVIORX_FEATURES,
|
|
136
|
+
attributes=MISBEHAVIORX_ATTRIBUTES,
|
|
137
|
+
window_size=10,
|
|
138
|
+
step_size=10,
|
|
139
|
+
status="manual",
|
|
140
|
+
notes=(
|
|
141
|
+
"Place curated ambient/ and attacks/ folders under the dataset root. "
|
|
142
|
+
"Automatic download is not wired yet."
|
|
143
|
+
),
|
|
144
|
+
),
|
|
145
|
+
"x-canids": DatasetSpec(
|
|
146
|
+
name="x-canids",
|
|
147
|
+
family="can",
|
|
148
|
+
description="X-CANIDS in-vehicle intrusion dataset.",
|
|
149
|
+
citation="Jeong et al., X-CANIDS.",
|
|
150
|
+
source_url="https://ieee-dataport.org/open-access/x-canids-dataset",
|
|
151
|
+
downloadable=False,
|
|
152
|
+
features=(),
|
|
153
|
+
attributes=CAN_ATTRIBUTES,
|
|
154
|
+
status="planned",
|
|
155
|
+
notes="Registered so experiments can target it, but the loader is not implemented yet.",
|
|
156
|
+
),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
ALIASES = {
|
|
160
|
+
"vasp": "misbehaviorx",
|
|
161
|
+
"veremi": "misbehaviorx",
|
|
162
|
+
"syncan_robids": "syncan",
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_spec(name: str) -> DatasetSpec:
|
|
167
|
+
key = name.strip().lower()
|
|
168
|
+
key = ALIASES.get(key, key)
|
|
169
|
+
if key not in SPECS:
|
|
170
|
+
known = ", ".join(sorted(SPECS))
|
|
171
|
+
raise KeyError(f"Unknown dataset '{name}'. Known datasets: {known}")
|
|
172
|
+
return SPECS[key]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def list_specs(status: Optional[str] = None) -> list[DatasetSpec]:
|
|
176
|
+
specs = list(SPECS.values())
|
|
177
|
+
if status is not None:
|
|
178
|
+
specs = [spec for spec in specs if spec.status == status]
|
|
179
|
+
return specs
|
cpsbench/v2x.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""V2X / MisbehaviorX window dataset.
|
|
2
|
+
|
|
3
|
+
Automatic download is not available. If curated CSVs already exist under
|
|
4
|
+
``<root>/misbehaviorx/{ambient,attacks}/generated``, they are converted to the
|
|
5
|
+
same window format as the CAN datasets.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pandas as pd
|
|
15
|
+
import torch
|
|
16
|
+
from torch.utils.data import Dataset
|
|
17
|
+
|
|
18
|
+
from .specs import DatasetSpec
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def prepare_v2x_split(
|
|
22
|
+
spec: DatasetSpec,
|
|
23
|
+
split_path: Path,
|
|
24
|
+
scaler_path: Path,
|
|
25
|
+
fit_scaler: bool,
|
|
26
|
+
) -> None:
|
|
27
|
+
split_path = Path(split_path)
|
|
28
|
+
generated = split_path / "generated"
|
|
29
|
+
csvs = sorted(generated.glob("*.csv"))
|
|
30
|
+
if not csvs and not list(generated.glob("sig_*.npy")):
|
|
31
|
+
raise FileNotFoundError(
|
|
32
|
+
f"MisbehaviorX files were not found in {generated}. "
|
|
33
|
+
"Automatic download is not implemented; place curated CSVs there first."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if fit_scaler and csvs and not scaler_path.exists():
|
|
37
|
+
scaler_path.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
overall_min = None
|
|
39
|
+
overall_max = None
|
|
40
|
+
for csv_path in csvs:
|
|
41
|
+
frame = pd.read_csv(csv_path, index_col=0)
|
|
42
|
+
current_min = frame[list(spec.features)].min(axis=0)
|
|
43
|
+
current_max = frame[list(spec.features)].max(axis=0)
|
|
44
|
+
overall_min = current_min if overall_min is None else np.minimum(overall_min, current_min)
|
|
45
|
+
overall_max = current_max if overall_max is None else np.maximum(overall_max, current_max)
|
|
46
|
+
pd.DataFrame({"Min": overall_min, "Max": overall_max}).to_csv(scaler_path)
|
|
47
|
+
|
|
48
|
+
if list(generated.glob("sig_*.npy")):
|
|
49
|
+
return
|
|
50
|
+
if not scaler_path.exists():
|
|
51
|
+
raise FileNotFoundError(
|
|
52
|
+
f"Missing scaler {scaler_path}. Prepare the train/ambient split first."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
for csv_path in csvs:
|
|
56
|
+
frame = pd.read_csv(csv_path, index_col=0)
|
|
57
|
+
if not fit_scaler and "attack_name" in frame.columns:
|
|
58
|
+
frame = frame[frame["attack_name"] != "No Attack"]
|
|
59
|
+
signals = frame[list(spec.features)]
|
|
60
|
+
attributes = frame[list(spec.attributes)].copy()
|
|
61
|
+
if spec.filling == "forward":
|
|
62
|
+
signals = signals.ffill().bfill()
|
|
63
|
+
if "attack_name" in attributes.columns:
|
|
64
|
+
attributes["attack_name"], categories = pd.factorize(attributes["attack_name"])
|
|
65
|
+
mapping = {str(category): int(code) for code, category in enumerate(categories)}
|
|
66
|
+
with open(split_path / "file_index_dict.json", "w", encoding="utf-8") as handle:
|
|
67
|
+
json.dump(mapping, handle, indent=2)
|
|
68
|
+
np.save(generated / f"sig_{csv_path.stem}_1.npy", signals.to_numpy())
|
|
69
|
+
np.save(generated / f"att_{csv_path.stem}_1.npy", attributes.to_numpy())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class V2XWindowDataset(Dataset):
|
|
73
|
+
"""Windowed V2X dataset. Returns ``(window, label)`` like MNIST."""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
spec: DatasetSpec,
|
|
78
|
+
data_dir: str | Path,
|
|
79
|
+
scaler_path: str | Path,
|
|
80
|
+
return_meta: bool = False,
|
|
81
|
+
verbose: bool = False,
|
|
82
|
+
) -> None:
|
|
83
|
+
self.spec = spec
|
|
84
|
+
self.data_dir = Path(data_dir)
|
|
85
|
+
self.return_meta = return_meta
|
|
86
|
+
self.features = list(spec.features)
|
|
87
|
+
generated = self.data_dir / "generated"
|
|
88
|
+
self.sig_files = sorted(generated.glob("sig_*.npy"))
|
|
89
|
+
self.att_files = sorted(generated.glob("att_*.npy"))
|
|
90
|
+
if not self.sig_files or len(self.sig_files) != len(self.att_files):
|
|
91
|
+
raise FileNotFoundError(f"Expected paired npy files in {generated}.")
|
|
92
|
+
|
|
93
|
+
table = pd.read_csv(scaler_path, index_col=0)
|
|
94
|
+
self.min_vals = table["Min"].loc[self.features].to_numpy(dtype=np.float32)
|
|
95
|
+
maxs = table["Max"].loc[self.features].to_numpy(dtype=np.float32)
|
|
96
|
+
span = np.where(maxs - self.min_vals == 0, 1.0, maxs - self.min_vals)
|
|
97
|
+
self.max_vals = self.min_vals + span
|
|
98
|
+
|
|
99
|
+
with open(self.data_dir / "file_index_dict.json", encoding="utf-8") as handle:
|
|
100
|
+
mapping = json.load(handle)
|
|
101
|
+
self.file_names = {int(value): key for key, value in mapping.items()}
|
|
102
|
+
self.index_map = self._index_windows()
|
|
103
|
+
if verbose:
|
|
104
|
+
print(f"{spec.name}: {len(self.index_map)} windows from {self.data_dir}")
|
|
105
|
+
|
|
106
|
+
def _index_windows(self) -> list[tuple[int, int]]:
|
|
107
|
+
index_map: list[tuple[int, int]] = []
|
|
108
|
+
for file_idx, (sig_file, att_file) in enumerate(zip(self.sig_files, self.att_files)):
|
|
109
|
+
sig = np.load(sig_file, mmap_mode="r")
|
|
110
|
+
att = pd.DataFrame(np.load(att_file, mmap_mode="r"), columns=list(self.spec.attributes))
|
|
111
|
+
groups = att[["id", "time_chunk", "attack_name"]].drop_duplicates()
|
|
112
|
+
for _, row in groups.iterrows():
|
|
113
|
+
mask = (
|
|
114
|
+
(att["id"] == row["id"])
|
|
115
|
+
& (att["time_chunk"] == row["time_chunk"])
|
|
116
|
+
& (att["attack_name"] == row["attack_name"])
|
|
117
|
+
)
|
|
118
|
+
indices = att.index[mask]
|
|
119
|
+
if len(indices) == 0 or indices[-1] - indices[0] + 1 != len(indices):
|
|
120
|
+
continue
|
|
121
|
+
n_windows = (len(indices) - self.spec.window_size) // self.spec.step_size + 1
|
|
122
|
+
start = int(indices[0])
|
|
123
|
+
for offset in range(max(n_windows, 0)):
|
|
124
|
+
index_map.append((file_idx, start + offset * self.spec.step_size))
|
|
125
|
+
return index_map
|
|
126
|
+
|
|
127
|
+
def __len__(self) -> int:
|
|
128
|
+
return len(self.index_map)
|
|
129
|
+
|
|
130
|
+
def __getitem__(self, idx: int):
|
|
131
|
+
file_idx, start = self.index_map[idx]
|
|
132
|
+
stop = start + self.spec.window_size
|
|
133
|
+
sig = np.load(self.sig_files[file_idx], mmap_mode="r")
|
|
134
|
+
att = np.load(self.att_files[file_idx], mmap_mode="r")
|
|
135
|
+
window = (sig[start:stop] - self.min_vals) / (self.max_vals - self.min_vals)
|
|
136
|
+
tensor = torch.tensor(window, dtype=torch.float32).unsqueeze(0)
|
|
137
|
+
label = int(np.sum(att[start:stop, self.spec.label_index]) > 0.0)
|
|
138
|
+
if not self.return_meta:
|
|
139
|
+
return tensor, label
|
|
140
|
+
file_id = int(att[start, 3])
|
|
141
|
+
meta = {"idx": int(start), "file": self.file_names.get(file_id, str(file_id))}
|
|
142
|
+
return tensor, label, meta
|
cpsbench/windows.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Windowed PyTorch datasets over preprocessed signal arrays."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import torch
|
|
11
|
+
from torch.utils.data import Dataset
|
|
12
|
+
|
|
13
|
+
from .specs import DatasetSpec
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WindowDataset(Dataset):
|
|
17
|
+
"""Sliding-window CAN dataset.
|
|
18
|
+
|
|
19
|
+
``__getitem__`` returns ``(window, label)`` like MNIST. Pass
|
|
20
|
+
``return_meta=True`` to also get ``{"file", "idx"}``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
spec: DatasetSpec,
|
|
26
|
+
data_dir: str | Path,
|
|
27
|
+
scaler_path: str | Path,
|
|
28
|
+
return_meta: bool = False,
|
|
29
|
+
verbose: bool = False,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.spec = spec
|
|
32
|
+
self.data_dir = Path(data_dir)
|
|
33
|
+
self.scaler_path = Path(scaler_path)
|
|
34
|
+
self.return_meta = return_meta
|
|
35
|
+
self.features = list(spec.features)
|
|
36
|
+
self.sampling_periods = np.asarray(spec.sampling_period_factors, dtype=int) * int(spec.sampling_period)
|
|
37
|
+
if len(self.sampling_periods) != len(self.features):
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"{spec.name} has {len(self.features)} features but "
|
|
40
|
+
f"{len(self.sampling_periods)} sampling-period factors."
|
|
41
|
+
)
|
|
42
|
+
self.max_sampling_period = int(self.sampling_periods.max())
|
|
43
|
+
|
|
44
|
+
generated = self.data_dir / "generated"
|
|
45
|
+
self.sig_files = sorted(generated.glob("sig_*.npy"))
|
|
46
|
+
self.att_files = sorted(generated.glob("att_*.npy"))
|
|
47
|
+
if not self.sig_files or len(self.sig_files) != len(self.att_files):
|
|
48
|
+
raise FileNotFoundError(
|
|
49
|
+
f"Expected paired sig_*.npy and att_*.npy files in {generated}."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
self.min_vals, self.max_vals = self._load_scaler()
|
|
53
|
+
self.file_names = self._load_file_names()
|
|
54
|
+
self.index_map = self._index_windows()
|
|
55
|
+
if verbose:
|
|
56
|
+
print(f"{spec.name}: {len(self.index_map)} windows from {self.data_dir}")
|
|
57
|
+
|
|
58
|
+
def _load_file_names(self) -> dict[int, str]:
|
|
59
|
+
with open(self.data_dir / "file_index_dict.json", encoding="utf-8") as handle:
|
|
60
|
+
mapping = json.load(handle)
|
|
61
|
+
return {int(value): key for key, value in mapping.items()}
|
|
62
|
+
|
|
63
|
+
def _load_scaler(self) -> tuple[np.ndarray, np.ndarray]:
|
|
64
|
+
table = pd.read_csv(self.scaler_path, index_col=0)
|
|
65
|
+
mins = table["Min"].loc[self.features].to_numpy(dtype=np.float32)
|
|
66
|
+
maxs = table["Max"].loc[self.features].to_numpy(dtype=np.float32)
|
|
67
|
+
span = np.where(maxs - mins == 0, 1.0, maxs - mins)
|
|
68
|
+
return mins, mins + span
|
|
69
|
+
|
|
70
|
+
def _index_windows(self) -> list[tuple[int, int]]:
|
|
71
|
+
index_map: list[tuple[int, int]] = []
|
|
72
|
+
span = self.spec.window_size * self.max_sampling_period
|
|
73
|
+
for file_idx, (sig_file, att_file) in enumerate(zip(self.sig_files, self.att_files)):
|
|
74
|
+
sig = np.load(sig_file, mmap_mode="r")
|
|
75
|
+
att = np.load(att_file, mmap_mode="r")
|
|
76
|
+
if sig.shape[1] != len(self.features) or att.shape[1] != len(self.spec.attributes):
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"Unexpected array shape in {sig_file.name}: signals {sig.shape}, attributes {att.shape}."
|
|
79
|
+
)
|
|
80
|
+
if sig.shape[0] != att.shape[0]:
|
|
81
|
+
raise ValueError(f"Signal/attribute length mismatch in {sig_file.name}.")
|
|
82
|
+
n_windows = (sig.shape[0] - span) // self.spec.step_size + 1
|
|
83
|
+
for offset in range(max(n_windows, 0)):
|
|
84
|
+
index_map.append((file_idx, offset * self.spec.step_size))
|
|
85
|
+
return index_map
|
|
86
|
+
|
|
87
|
+
def __len__(self) -> int:
|
|
88
|
+
return len(self.index_map)
|
|
89
|
+
|
|
90
|
+
def __getitem__(self, idx: int):
|
|
91
|
+
file_idx, start = self.index_map[idx]
|
|
92
|
+
span = self.spec.window_size * self.max_sampling_period
|
|
93
|
+
sig = np.load(self.sig_files[file_idx], mmap_mode="r")
|
|
94
|
+
att = np.load(self.att_files[file_idx], mmap_mode="r")
|
|
95
|
+
stop = start + span
|
|
96
|
+
if stop > sig.shape[0]:
|
|
97
|
+
raise IndexError(f"Window {idx} exceeds {self.sig_files[file_idx].name}.")
|
|
98
|
+
|
|
99
|
+
rows = np.arange(self.spec.window_size)[:, None] * self.sampling_periods
|
|
100
|
+
window = sig[start:stop][rows, np.arange(len(self.features))]
|
|
101
|
+
window = (window - self.min_vals) / (self.max_vals - self.min_vals)
|
|
102
|
+
tensor = torch.tensor(window, dtype=torch.float32).unsqueeze(0)
|
|
103
|
+
|
|
104
|
+
att_window = att[start:stop]
|
|
105
|
+
label = int(np.sum(att_window[:, self.spec.label_index]) > 0.0)
|
|
106
|
+
if not self.return_meta:
|
|
107
|
+
return tensor, label
|
|
108
|
+
|
|
109
|
+
file_id = int(att_window[0, 3])
|
|
110
|
+
meta = {"idx": int(start), "file": self.file_names.get(file_id, str(file_id))}
|
|
111
|
+
return tensor, label, meta
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: CPSBench
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cyber-physical security datasets that download, preprocess, and load like MNIST.
|
|
5
|
+
Author: Md Hasan Shahriar
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/shahriar0651/CPSBench
|
|
8
|
+
Keywords: dataset,cps,can,ids,vehicular,cybersecurity,pytorch
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: numpy>=1.23
|
|
18
|
+
Requires-Dist: pandas>=2.0
|
|
19
|
+
Requires-Dist: pyarrow>=14
|
|
20
|
+
Requires-Dist: joblib>=1.3
|
|
21
|
+
Requires-Dist: torch>=2.0
|
|
22
|
+
Requires-Dist: tqdm>=4.60
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# CPSBench
|
|
28
|
+
|
|
29
|
+
Installable loader for cyber-physical security datasets. It downloads the raw traces, builds the windowed tensors, and returns a PyTorch dataset with the same contract as MNIST: `(window, label)`.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from cpsbench import SynCAN
|
|
33
|
+
from torch.utils.data import DataLoader
|
|
34
|
+
|
|
35
|
+
train = SynCAN(root="./data", split="train", download=True)
|
|
36
|
+
test = SynCAN(root="./data", split="test", download=True)
|
|
37
|
+
|
|
38
|
+
window, label = train[0] # window: (1, time, signals), label: 0 benign / 1 attack
|
|
39
|
+
loader = DataLoader(train, batch_size=64, shuffle=True)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or load by name:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import cpsbench
|
|
46
|
+
|
|
47
|
+
dataset = cpsbench.load("road", root="./data", split="test", download=True)
|
|
48
|
+
print(dataset.input_shape) # (channels, window, signals)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install cpsbench
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
That works after the package is published on PyPI. Until then, install this repository directly:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install "git+https://github.com/shahriar0651/CPSBench.git"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
From a local clone, for development:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install -e .
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Python 3.10+. SynCAN also needs `git` on `PATH`. ROAD is fetched from Zenodo with the standard library, so `wget` is not required.
|
|
70
|
+
|
|
71
|
+
## Datasets
|
|
72
|
+
|
|
73
|
+
| Name | Status | What you get |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| `syncan` | ready, auto-download | Synthetic CAN intrusion traces |
|
|
76
|
+
| `road` | ready, auto-download | ROAD dynamometer CAN traces |
|
|
77
|
+
| `misbehaviorx` | loader ready, manual files | V2X misbehavior (also accepted as `vasp`) |
|
|
78
|
+
| `x-canids` | registered, not implemented | Raises a clear error until a loader is added |
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
python -m cpsbench list
|
|
82
|
+
python -m cpsbench info syncan
|
|
83
|
+
python -m cpsbench download syncan --root ./data --split train
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Downloaded files land in `<root>/<name>/{ambient,attacks}` plus a fitted min/max scaler under `<root>/<name>/scaler`. Later calls reuse those files.
|
|
87
|
+
|
|
88
|
+
## Overrides
|
|
89
|
+
|
|
90
|
+
Windowing defaults live in the library so a new project does not need the old Hydra YAML. Override them per call:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from cpsbench import ROAD
|
|
94
|
+
|
|
95
|
+
dataset = ROAD(root="./data", split="train", download=True, window_size=50, step_size=5)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Pass `return_meta=True` if you also need the source file and row index: `(window, label, {"file", "idx"})`.
|
|
99
|
+
|
|
100
|
+
## Layout
|
|
101
|
+
|
|
102
|
+
Each sample is a min-max scaled window with a channel axis, so the same convolutional IDS can run on every dataset. Shape is always `(channels, window_size, num_signals)`. Label `0` is benign and `1` is attack (any attack flag inside the window).
|
|
103
|
+
|
|
104
|
+
## Adding a dataset
|
|
105
|
+
|
|
106
|
+
1. Add a `DatasetSpec` in `src/cpsbench/specs.py`.
|
|
107
|
+
2. Add a downloader in `src/cpsbench/download.py` if the files can be fetched automatically.
|
|
108
|
+
3. Register the class in `src/cpsbench/datasets.py` and `_CLASSES` in `__init__.py`.
|
|
109
|
+
|
|
110
|
+
The IDS experiments that consume this package live in the sibling [RobIDS](https://github.com/shahriar0651/robids) repo.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
cpsbench/__init__.py,sha256=Q_CO_KyeJV0M-19XUfhwiBgwsRO9eES5WviSzpX-I_Y,1999
|
|
2
|
+
cpsbench/__main__.py,sha256=MEWP6zPkTYeV-kAXOG10J3_vv0ANIBHTav_3MG0Cne8,1293
|
|
3
|
+
cpsbench/datasets.py,sha256=OiZUQkueOvNgRwYfLLObaHZd1DJYqIruXzwbTwOpUkc,4145
|
|
4
|
+
cpsbench/download.py,sha256=ZTUI2sCJuRP-9tPegw2b0QlXIerl4B5NZGHKP3wOHLA,5627
|
|
5
|
+
cpsbench/preprocess.py,sha256=FdNI5RuP0e2zDX41mHvNyYiSUI79PB4kRNbqJxSd3rU,6372
|
|
6
|
+
cpsbench/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
7
|
+
cpsbench/specs.py,sha256=3yqpMLhFKBnXSDtTR5ICZ39y__cmnr4ahG39gCkBLnM,5926
|
|
8
|
+
cpsbench/v2x.py,sha256=hso30XucXdFDMvYoIAfB73E1tKr1r33bEqHvg6Rjw3M,6157
|
|
9
|
+
cpsbench/windows.py,sha256=Xd3nbtcLP5lQBFVfStwpZTZ-OuyqpWDkH8bWe2Ci4d0,4641
|
|
10
|
+
cpsbench-0.1.0.dist-info/licenses/LICENSE,sha256=puRg9gSMJy3domQ53eusoFeQHNemw5TNmcSDIwoSMUY,1074
|
|
11
|
+
cpsbench-0.1.0.dist-info/METADATA,sha256=B3--wcOhwMwXUOZ_-Y28SMX0K5gsTq7hDx8oRs-5AxI,3731
|
|
12
|
+
cpsbench-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
cpsbench-0.1.0.dist-info/top_level.txt,sha256=22JwhlbHTxWz-RtuXKzL9VTGRMl6jayXan1wH9LkE4c,9
|
|
14
|
+
cpsbench-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Md Hasan Shahriar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cpsbench
|