koios-model-utils 1.0.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ """koios-model-utils — utilities for exporting and annotating ONNX models for the Koios IoT platform."""
2
+
3
+ __version__ = "1.0.0rc1"
4
+
5
+ from koios_model_utils.metadata import (
6
+ InputBinding,
7
+ OutputBinding,
8
+ TrainingMeta,
9
+ build_input_bindings,
10
+ build_output_bindings,
11
+ embed_koios_metadata,
12
+ )
13
+
14
+ # SB3-dependent exports — only available with koios-model-utils[sb3]
15
+ try:
16
+ from koios_model_utils.callbacks import (
17
+ ObsRangeCallback,
18
+ VecNormalizeSyncCallback,
19
+ )
20
+ from koios_model_utils.export import (
21
+ OnnxActorWrapper,
22
+ OnnxDQNWrapper,
23
+ detect_algorithm,
24
+ export_onnx,
25
+ load_vec_normalize_stats,
26
+ )
27
+ except ImportError:
28
+ pass
29
+
30
+ __all__ = [
31
+ # metadata (always available)
32
+ "InputBinding",
33
+ "OutputBinding",
34
+ "TrainingMeta",
35
+ "embed_koios_metadata",
36
+ "build_input_bindings",
37
+ "build_output_bindings",
38
+ # export (requires sb3 extra)
39
+ "export_onnx",
40
+ "detect_algorithm",
41
+ "load_vec_normalize_stats",
42
+ "OnnxActorWrapper",
43
+ "OnnxDQNWrapper",
44
+ # callbacks (requires sb3 extra)
45
+ "ObsRangeCallback",
46
+ "VecNormalizeSyncCallback",
47
+ ]
@@ -0,0 +1,147 @@
1
+ """SB3 training callbacks for Koios model export."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ import numpy as np
10
+ from stable_baselines3.common.callbacks import BaseCallback
11
+
12
+ if TYPE_CHECKING:
13
+ from stable_baselines3.common.callbacks import EvalCallback
14
+
15
+
16
+ class ObsRangeCallback(BaseCallback):
17
+ """Track per-feature min/max of raw (pre-normalization) observations.
18
+
19
+ Saves ``obs_range.json`` to the run directory on training end, containing
20
+ the observed min/max per feature across all training steps. These are used
21
+ as failure bounds in Koios — if a live value falls outside the range the
22
+ model was trained on, the binding is flagged as untrusted.
23
+
24
+ Works with both raw envs and VecNormalize-wrapped envs. When VecNormalize
25
+ is present, the original (un-normalized) observations are tracked.
26
+ """
27
+
28
+ def __init__(self, run_dir: str | Path, verbose: int = 0) -> None:
29
+ super().__init__(verbose)
30
+ self._run_dir = Path(run_dir)
31
+ self._obs_min: np.ndarray | None = None
32
+ self._obs_max: np.ndarray | None = None
33
+
34
+ def _on_step(self) -> bool:
35
+ # Get raw observations — use original obs when normalization is active
36
+ obs = None
37
+
38
+ # Path 1: VecNormalize (z_score)
39
+ vec_norm = self.model._vec_normalize_env # type: ignore[union-attr]
40
+ if vec_norm is not None:
41
+ obs = vec_norm.get_original_obs()
42
+
43
+ # Path 2: MinMaxObsWrapper — walk wrapper chain for _last_original_obs
44
+ if obs is None:
45
+ env = self.training_env
46
+ # Unwrap through VecEnv layers to find the underlying env
47
+ while hasattr(env, "envs"):
48
+ env = env.envs[0] # type: ignore[union-attr]
49
+ # Walk Gymnasium wrapper chain
50
+ current = env
51
+ while current is not None:
52
+ if hasattr(current, "_last_original_obs"):
53
+ obs = current._last_original_obs
54
+ break
55
+ current = getattr(current, "env", None)
56
+
57
+ # Path 3: fallback to raw obs
58
+ if obs is None:
59
+ obs = self.locals.get("new_obs")
60
+
61
+ if obs is None:
62
+ return True
63
+
64
+ obs = np.asarray(obs, dtype=np.float32)
65
+
66
+ # Flatten to (n_envs, num_features) — collapse obs_depth if present
67
+ if obs.ndim == 3:
68
+ # (n_envs, obs_depth, num_features) → min/max across envs and depth
69
+ flat = obs.reshape(-1, obs.shape[-1])
70
+ elif obs.ndim == 2:
71
+ flat = obs
72
+ else:
73
+ flat = obs.reshape(1, -1)
74
+
75
+ step_min = flat.min(axis=0)
76
+ step_max = flat.max(axis=0)
77
+
78
+ if self._obs_min is None:
79
+ self._obs_min = step_min.copy()
80
+ self._obs_max = step_max.copy()
81
+ else:
82
+ np.minimum(self._obs_min, step_min, out=self._obs_min)
83
+ np.maximum(self._obs_max, step_max, out=self._obs_max)
84
+
85
+ return True
86
+
87
+ def _on_training_end(self) -> None:
88
+ if self._obs_min is None or self._obs_max is None:
89
+ return
90
+
91
+ out_path = self._run_dir / "obs_range.json"
92
+ data = {
93
+ "obs_min": self._obs_min.tolist(),
94
+ "obs_max": self._obs_max.tolist(),
95
+ }
96
+ with open(out_path, "w") as f:
97
+ json.dump(data, f, indent=2)
98
+
99
+ if self.verbose >= 1:
100
+ print(f"Observation ranges saved to {out_path}")
101
+ for i, (lo, hi) in enumerate(zip(self._obs_min, self._obs_max)):
102
+ print(f" feature {i}: [{lo:.4f}, {hi:.4f}]")
103
+
104
+
105
+ class VecNormalizeSyncCallback(BaseCallback):
106
+ """Save VecNormalize stats alongside every new best model.
107
+
108
+ SB3's ``EvalCallback`` saves ``best_model.zip`` when eval reward improves,
109
+ but doesn't save the VecNormalize running statistics. Without the stats
110
+ file, an early ``koios-export`` on ``best_model.zip`` produces an ONNX with
111
+ no z-score metadata — the PE then feeds raw observations to a model that
112
+ expects normalized ones.
113
+
114
+ This callback checks after each eval whether a new best model was saved
115
+ and, if so, writes ``vec_normalize.pkl`` to the same directory.
116
+ """
117
+
118
+ def __init__(self, eval_callback: EvalCallback, verbose: int = 0) -> None:
119
+ super().__init__(verbose)
120
+ self._eval_cb = eval_callback
121
+ self._last_best_timestep: int = -1
122
+
123
+ def _on_step(self) -> bool:
124
+ # EvalCallback sets best_mean_reward and n_calls when a new best is saved
125
+ best_ts = getattr(self._eval_cb, "best_mean_reward_timestep", None)
126
+ if best_ts is None:
127
+ # Fallback: check if best_model.zip was updated by comparing n_calls
128
+ best_ts = getattr(self._eval_cb, "n_calls", 0)
129
+
130
+ # Detect new best model by checking if EvalCallback updated its best
131
+ vec_norm = self.model._vec_normalize_env # type: ignore[union-attr]
132
+ if vec_norm is None:
133
+ return True
134
+
135
+ best_path = Path(self._eval_cb.best_model_save_path) / "best_model.zip"
136
+ stats_path = Path(self._eval_cb.best_model_save_path) / "vec_normalize.pkl"
137
+
138
+ # Save if best_model.zip exists and is newer than our last save
139
+ if best_path.exists():
140
+ mtime = best_path.stat().st_mtime_ns
141
+ if mtime != self._last_best_timestep:
142
+ self._last_best_timestep = mtime
143
+ vec_norm.save(str(stats_path))
144
+ if self.verbose >= 1:
145
+ print(f"[VecNormSync] Saved {stats_path}")
146
+
147
+ return True
@@ -0,0 +1,662 @@
1
+ """ONNX export — ``koios-export runs/tank_level_ppo/best_model.zip``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import pickle
8
+ import sys
9
+ import zipfile
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ import torch
14
+ import torch.nn as nn
15
+
16
+ from koios_model_utils.metadata import (
17
+ InputBinding,
18
+ OutputBinding,
19
+ TrainingMeta,
20
+ build_input_bindings,
21
+ build_output_bindings,
22
+ embed_koios_metadata,
23
+ )
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Algorithm detection
27
+ # ---------------------------------------------------------------------------
28
+
29
+ _ALGO_CLASS_PREFIXES = {
30
+ "stable_baselines3.ppo": "PPO",
31
+ "stable_baselines3.sac": "SAC",
32
+ "stable_baselines3.td3": "TD3",
33
+ "stable_baselines3.a2c": "A2C",
34
+ "stable_baselines3.dqn": "DQN",
35
+ }
36
+
37
+
38
+ def detect_algorithm(model_path: str | Path) -> str:
39
+ """Detect the SB3 algorithm from a saved .zip model.
40
+
41
+ SB3 stores a ``data`` file inside the zip containing pickled metadata
42
+ that includes the class path. We read this without unpickling by
43
+ scanning for the class prefix strings.
44
+ """
45
+ model_path = Path(model_path)
46
+ with zipfile.ZipFile(model_path, "r") as zf:
47
+ if "data" in zf.namelist():
48
+ raw = zf.read("data")
49
+ text = raw.decode("latin-1") # binary-safe decode for scanning
50
+ for prefix, algo_name in _ALGO_CLASS_PREFIXES.items():
51
+ if prefix in text:
52
+ return algo_name
53
+
54
+ # Fallback: try to infer from the directory or filename
55
+ name_lower = model_path.stem.lower()
56
+ for algo in ("sac", "td3", "a2c", "ppo"):
57
+ if algo in name_lower:
58
+ return algo.upper()
59
+
60
+ return "PPO" # Default assumption
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # ONNX actor wrappers (require torch — imported lazily)
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ class OnnxActorWrapper(nn.Module):
69
+ """Wraps an SB3 policy's actor-only forward path for ONNX export.
70
+
71
+ Extracts the deterministic actor from both on-policy (PPO, A2C) and
72
+ off-policy (SAC, TD3) architectures:
73
+
74
+ - PPO/A2C: features_extractor -> mlp_extractor.policy_net -> action_net -> clamp
75
+ - SAC: features_extractor -> actor.latent_pi -> actor.mu -> tanh -> clamp
76
+ - TD3: features_extractor -> actor.latent_pi -> actor.mu -> clamp
77
+
78
+ SAC uses a SquashedDiagGaussianDistribution — deterministic actions are
79
+ ``tanh(mu)``, not raw ``mu``. When *squash_output* is True, ``tanh`` is
80
+ applied before clamping to match SB3's ``predict(deterministic=True)``.
81
+
82
+ Normalization and denormalization are NOT baked in — the Koios predict
83
+ engine applies them based on embedded ``koios.bindings`` metadata.
84
+
85
+ Forward: obs [1, obs_depth, num_features] -> actions [1, num_actions]
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ policy: nn.Module,
91
+ action_low: torch.Tensor,
92
+ action_high: torch.Tensor,
93
+ *,
94
+ squash_output: bool = False,
95
+ ) -> None:
96
+ super().__init__()
97
+ self.register_buffer("action_low", action_low)
98
+ self.register_buffer("action_high", action_high)
99
+ self.squash_output = squash_output
100
+
101
+ # Detect architecture: SAC/TD3 have policy.actor, PPO/A2C have mlp_extractor
102
+ if hasattr(policy, "actor") and hasattr(policy.actor, "latent_pi"):
103
+ # SAC / TD3: everything lives on the actor sub-module
104
+ self.features_extractor = policy.actor.features_extractor
105
+ self.hidden_net = policy.actor.latent_pi
106
+ self.output_net = policy.actor.mu
107
+ else:
108
+ # PPO / A2C: features_extractor on policy, actor split across mlp_extractor + action_net
109
+ self.features_extractor = policy.features_extractor
110
+ self.hidden_net = policy.mlp_extractor.policy_net
111
+ self.output_net = policy.action_net
112
+
113
+ def forward(self, obs: torch.Tensor) -> torch.Tensor:
114
+ features = self.features_extractor(obs)
115
+ latent = self.hidden_net(features)
116
+ actions = self.output_net(latent)
117
+ if self.squash_output:
118
+ actions = torch.tanh(actions)
119
+ actions = torch.clamp(actions, self.action_low, self.action_high)
120
+ return actions
121
+
122
+
123
+ class OnnxDQNWrapper(nn.Module):
124
+ """Wraps DQN's Q-network for ONNX export.
125
+
126
+ Outputs raw Q-values ``[1, num_actions]``. The Koios predict engine
127
+ applies ``argmax`` and ``action_map`` lookup.
128
+
129
+ Forward: obs ``[1, obs_depth, num_features]`` -> q_values ``[1, num_actions]``
130
+ """
131
+
132
+ def __init__(self, policy: nn.Module) -> None:
133
+ super().__init__()
134
+ self.features_extractor = policy.q_net.features_extractor
135
+ self.q_net = policy.q_net.q_net # The actual Q-value MLP
136
+
137
+ def forward(self, obs: torch.Tensor) -> torch.Tensor:
138
+ features = self.features_extractor(obs)
139
+ return self.q_net(features)
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # VecNormalize stats loader
144
+ # ---------------------------------------------------------------------------
145
+
146
+
147
+ def load_vec_normalize_stats(
148
+ stats_path: str | Path,
149
+ ) -> tuple[np.ndarray, np.ndarray, float] | None:
150
+ """Load observation mean/var from a saved VecNormalize pickle.
151
+
152
+ Returns (mean, var, clip_obs) or None if loading fails.
153
+ """
154
+ try:
155
+ with open(stats_path, "rb") as f:
156
+ vec_normalize = pickle.load(f)
157
+ mean = np.array(vec_normalize.obs_rms.mean, dtype=np.float32)
158
+ var = np.array(vec_normalize.obs_rms.var, dtype=np.float32)
159
+ clip_obs = float(getattr(vec_normalize, "clip_obs", 10.0))
160
+ return mean, var, clip_obs
161
+ except Exception as exc:
162
+ print(f"WARNING: Could not load VecNormalize stats from {stats_path}: {exc}")
163
+ return None
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # Export function
168
+ # ---------------------------------------------------------------------------
169
+
170
+
171
+ def export_onnx(
172
+ model_path: str | Path,
173
+ output_path: str | Path | None = None,
174
+ *,
175
+ inputs: list[InputBinding] | None = None,
176
+ outputs: list[OutputBinding] | None = None,
177
+ training: TrainingMeta | None = None,
178
+ opset_version: int = 18,
179
+ verify: bool = True,
180
+ num_verify_samples: int = 100,
181
+ ) -> Path:
182
+ """Export an SB3 model to ONNX for the Koios predict engine.
183
+
184
+ The exported ONNX contains only the raw actor network — no observation
185
+ normalization or output denormalization is baked in. Instead, those
186
+ parameters are embedded in ``koios.bindings`` metadata so the predict
187
+ engine can apply them per-binding:
188
+
189
+ - **Inputs**: Z-Score normalization from VecNormalize running stats.
190
+ - **Outputs**: Symmetric denormalization from [-1, 1] to action ranges.
191
+
192
+ Parameters
193
+ ----------
194
+ model_path:
195
+ Path to an SB3 ``.zip`` model file.
196
+ output_path:
197
+ Destination ``.onnx`` file. Defaults to the same directory and stem
198
+ as *model_path* with ``.onnx`` extension.
199
+ inputs:
200
+ Optional input bindings for Koios metadata embedding.
201
+ outputs:
202
+ Optional output bindings for Koios metadata embedding.
203
+ training:
204
+ Optional training metadata for Koios metadata embedding.
205
+ opset_version:
206
+ ONNX opset version (default 18).
207
+ verify:
208
+ If True, validate the exported ONNX and compare outputs with SB3.
209
+ num_verify_samples:
210
+ Number of random observations to compare during verification.
211
+
212
+ Returns
213
+ -------
214
+ Path to the exported ``.onnx`` file.
215
+ """
216
+ import onnx
217
+ import stable_baselines3 as sb3
218
+
219
+ model_path = Path(model_path)
220
+ if output_path is None:
221
+ output_path = model_path.with_suffix(".onnx")
222
+ else:
223
+ output_path = Path(output_path)
224
+
225
+ # ------------------------------------------------------------------
226
+ # 1. Load SB3 model
227
+ # ------------------------------------------------------------------
228
+ algo_name = detect_algorithm(model_path)
229
+ algo_map = {"PPO": sb3.PPO, "SAC": sb3.SAC, "TD3": sb3.TD3, "A2C": sb3.A2C, "DQN": sb3.DQN}
230
+ algo_cls = algo_map[algo_name]
231
+ model = algo_cls.load(str(model_path), device="cpu")
232
+ print(f"Loaded {algo_name} model from {model_path}")
233
+
234
+ policy = model.policy
235
+ policy.eval()
236
+
237
+ obs_space = model.observation_space
238
+ act_space = model.action_space
239
+ is_dqn = algo_name == "DQN"
240
+
241
+ # ------------------------------------------------------------------
242
+ # 2. Load observation normalization stats (if VecNormalize was used)
243
+ # ------------------------------------------------------------------
244
+ obs_stats_np: tuple[np.ndarray, np.ndarray, float, float] | None = None
245
+ obs_clip: float = 10.0
246
+ obs_epsilon: float = 1e-8
247
+
248
+ stats_path = model_path.parent / "vec_normalize.pkl"
249
+ if stats_path.exists():
250
+ stats = load_vec_normalize_stats(stats_path)
251
+ if stats is not None:
252
+ mean_np, var_np, obs_clip = stats
253
+ obs_stats_np = (mean_np, var_np, obs_clip, obs_epsilon)
254
+ print(f"Loaded VecNormalize stats from {stats_path} (will embed in metadata)")
255
+ print(f" obs shape: {mean_np.shape}, clip: {obs_clip}")
256
+
257
+ # ------------------------------------------------------------------
258
+ # 3. Create ONNX wrapper (raw actor — no baked normalization)
259
+ # ------------------------------------------------------------------
260
+ if is_dqn:
261
+ wrapper = OnnxDQNWrapper(policy)
262
+ output_names = ["q_values"]
263
+ else:
264
+ action_low = torch.tensor(act_space.low, dtype=torch.float32)
265
+ action_high = torch.tensor(act_space.high, dtype=torch.float32)
266
+
267
+ squash = algo_name == "SAC"
268
+
269
+ wrapper = OnnxActorWrapper(
270
+ policy,
271
+ action_low,
272
+ action_high,
273
+ squash_output=squash,
274
+ )
275
+ output_names = ["actions"]
276
+ wrapper.eval()
277
+
278
+ # ------------------------------------------------------------------
279
+ # 4. Build dummy input with static shape [1, obs_depth, num_features]
280
+ # ------------------------------------------------------------------
281
+ obs_shape = obs_space.shape
282
+ dummy_input = torch.randn(1, *obs_shape, dtype=torch.float32)
283
+
284
+ # ------------------------------------------------------------------
285
+ # 5. Export to ONNX
286
+ # ------------------------------------------------------------------
287
+ torch.onnx.export(
288
+ wrapper,
289
+ (dummy_input,),
290
+ str(output_path),
291
+ opset_version=opset_version,
292
+ input_names=["observations"],
293
+ output_names=output_names,
294
+ dynamic_axes=None,
295
+ )
296
+ print(f"Exported ONNX model to {output_path}")
297
+
298
+ # ------------------------------------------------------------------
299
+ # 5b. Consolidate external data into single file
300
+ # ------------------------------------------------------------------
301
+ data_path = output_path.parent / (output_path.name + ".data")
302
+ if data_path.exists():
303
+ onnx_model = onnx.load(str(output_path), load_external_data=True)
304
+ for init in onnx_model.graph.initializer:
305
+ if init.data_location == onnx.TensorProto.EXTERNAL:
306
+ init.data_location = onnx.TensorProto.DEFAULT
307
+ onnx.save_model(onnx_model, str(output_path))
308
+ data_path.unlink()
309
+ print("Consolidated external data into single .onnx file")
310
+
311
+ # ------------------------------------------------------------------
312
+ # 6. Validate with onnx checker
313
+ # ------------------------------------------------------------------
314
+ onnx_model = onnx.load(str(output_path))
315
+ onnx.checker.check_model(onnx_model)
316
+ print("ONNX model passes onnx.checker validation")
317
+
318
+ # ------------------------------------------------------------------
319
+ # 6b. Embed Koios training metadata
320
+ # ------------------------------------------------------------------
321
+ if inputs is not None and outputs is not None:
322
+ embed_koios_metadata(
323
+ onnx_model,
324
+ inputs=inputs,
325
+ outputs=outputs,
326
+ training=training,
327
+ )
328
+ onnx.save_model(onnx_model, str(output_path))
329
+
330
+ # ------------------------------------------------------------------
331
+ # 7. Verify outputs match SB3
332
+ # ------------------------------------------------------------------
333
+ if verify:
334
+ if is_dqn:
335
+ _verify_dqn_outputs(policy, wrapper, obs_space, num_verify_samples)
336
+ else:
337
+ _verify_outputs(
338
+ model,
339
+ wrapper,
340
+ obs_space,
341
+ num_verify_samples,
342
+ obs_mean_np=obs_stats_np[0] if obs_stats_np else None,
343
+ obs_var_np=obs_stats_np[1] if obs_stats_np else None,
344
+ obs_clip=obs_clip,
345
+ obs_epsilon=obs_epsilon,
346
+ )
347
+
348
+ # ------------------------------------------------------------------
349
+ # 8. Print summary
350
+ # ------------------------------------------------------------------
351
+ input_shape = [d.dim_value for d in onnx_model.graph.input[0].type.tensor_type.shape.dim]
352
+ output_shape = [d.dim_value for d in onnx_model.graph.output[0].type.tensor_type.shape.dim]
353
+
354
+ print(f"\n{'=' * 50}")
355
+ print("ONNX Export Summary")
356
+ print(f"{'=' * 50}")
357
+ print(f"Algorithm: {algo_name}")
358
+ print("Input name: observations")
359
+ print(f"Input shape: {input_shape}")
360
+ print(f"Output name: {output_names[0]}")
361
+ print(f"Output shape: {output_shape}")
362
+ print(f"Opset: {opset_version}")
363
+ if obs_stats_np is not None:
364
+ print("Obs normalization: Z-Score (Koios-applied)")
365
+ elif inputs is not None and any(b.normalization_type == "min_max" for b in inputs):
366
+ print("Obs normalization: Min/Max (Koios-applied)")
367
+ else:
368
+ print("Obs normalization: none")
369
+ if inputs is not None:
370
+ print("Output denorm: Symmetric (Koios-applied)")
371
+
372
+ if input_shape and len(input_shape) == 3:
373
+ print("\nPredict Engine mapping:")
374
+ print(f" depth_samples = {input_shape[1]}")
375
+ print(f" num_features = {input_shape[2]}")
376
+ print(f" num_outputs = {output_shape[1] if len(output_shape) > 1 else 1}")
377
+
378
+ if outputs is not None:
379
+ print("\nAction node mapping:")
380
+ for i, out in enumerate(outputs):
381
+ print(f" binding_order={i + 1} -> {out.name}")
382
+
383
+ print(f"{'=' * 50}")
384
+ return output_path
385
+
386
+
387
+ # ---------------------------------------------------------------------------
388
+ # Verification helpers
389
+ # ---------------------------------------------------------------------------
390
+
391
+
392
+ def _verify_outputs(
393
+ sb3_model: object,
394
+ wrapper: object,
395
+ obs_space: object,
396
+ num_samples: int,
397
+ obs_mean_np: np.ndarray | None = None,
398
+ obs_var_np: np.ndarray | None = None,
399
+ obs_clip: float = 10.0,
400
+ obs_epsilon: float = 1e-8,
401
+ ) -> None:
402
+ """Compare SB3 predict() with ONNX wrapper on random observations."""
403
+ max_action_diff = 0.0
404
+ rng = np.random.default_rng(42)
405
+
406
+ low = np.where(np.isfinite(obs_space.low), obs_space.low, -1.0) # type: ignore[union-attr]
407
+ high = np.where(np.isfinite(obs_space.high), obs_space.high, 1.0) # type: ignore[union-attr]
408
+
409
+ for _ in range(num_samples):
410
+ raw_obs = rng.uniform(low, high).astype(np.float32)
411
+
412
+ if obs_mean_np is not None and obs_var_np is not None:
413
+ norm_obs = (raw_obs - obs_mean_np) / np.sqrt(obs_var_np + obs_epsilon)
414
+ norm_obs = np.clip(norm_obs, -obs_clip, obs_clip)
415
+ else:
416
+ norm_obs = raw_obs
417
+
418
+ sb3_action, _ = sb3_model.predict(norm_obs, deterministic=True) # type: ignore[union-attr]
419
+
420
+ obs_tensor = torch.from_numpy(norm_obs).unsqueeze(0)
421
+ with torch.no_grad():
422
+ onnx_action = wrapper(obs_tensor).numpy().squeeze(0)
423
+
424
+ diff = np.max(np.abs(sb3_action - onnx_action))
425
+ max_action_diff = max(max_action_diff, diff)
426
+
427
+ print(f"Verification: max action diff across {num_samples} samples = {max_action_diff:.2e}")
428
+ if max_action_diff > 1e-5:
429
+ print(f"WARNING: max action diff {max_action_diff:.2e} exceeds 1e-5 threshold")
430
+ else:
431
+ print("Action verification PASSED (max diff < 1e-5)")
432
+
433
+
434
+ def _verify_dqn_outputs(
435
+ policy: object,
436
+ wrapper: object,
437
+ obs_space: object,
438
+ num_samples: int,
439
+ ) -> None:
440
+ """Compare DQN Q-network outputs with the ONNX wrapper on random observations."""
441
+ max_diff = 0.0
442
+ rng = np.random.default_rng(42)
443
+
444
+ low = np.where(np.isfinite(obs_space.low), obs_space.low, -1.0) # type: ignore[union-attr]
445
+ high = np.where(np.isfinite(obs_space.high), obs_space.high, 1.0) # type: ignore[union-attr]
446
+
447
+ for _ in range(num_samples):
448
+ raw_obs = rng.uniform(low, high).astype(np.float32)
449
+ obs_tensor = torch.from_numpy(raw_obs).unsqueeze(0)
450
+
451
+ with torch.no_grad():
452
+ sb3_qvals = policy.q_net(obs_tensor) # type: ignore[union-attr]
453
+ onnx_qvals = wrapper(obs_tensor)
454
+
455
+ diff = float(torch.max(torch.abs(sb3_qvals - onnx_qvals)).item())
456
+ max_diff = max(max_diff, diff)
457
+
458
+ print(f"Verification: max Q-value diff across {num_samples} samples = {max_diff:.2e}")
459
+ if max_diff > 1e-5:
460
+ print(f"WARNING: max Q-value diff {max_diff:.2e} exceeds 1e-5 threshold")
461
+ else:
462
+ print("Q-value verification PASSED (max diff < 1e-5)")
463
+
464
+
465
+ # ---------------------------------------------------------------------------
466
+ # CLI config bridge
467
+ # ---------------------------------------------------------------------------
468
+
469
+
470
+ def _bindings_from_config(
471
+ config: object,
472
+ model_path: Path,
473
+ obs_stats: tuple[np.ndarray, np.ndarray, float, float] | None = None,
474
+ obs_norm_minmax: tuple[np.ndarray, np.ndarray] | None = None,
475
+ obs_range: tuple[np.ndarray, np.ndarray] | None = None,
476
+ ) -> tuple[list[InputBinding], list[OutputBinding], TrainingMeta]:
477
+ """Build bindings from a sim_training ExperimentConfig (lazy import).
478
+
479
+ This bridges the old --config CLI interface. It requires
480
+ koios-scenario-simulator to be installed.
481
+ """
482
+ from sim_training.config import ExperimentConfig
483
+ from sim_training.env import make_env
484
+
485
+ assert isinstance(config, ExperimentConfig)
486
+
487
+ env = make_env(config)
488
+ unwrapped = env.unwrapped
489
+
490
+ # Build description lookup from scenario
491
+ probe = unwrapped._scenario_cls(rng=np.random.default_rng(0))
492
+ node_specs = probe.define_nodes()
493
+ desc_map = {s.name: s.description for s in node_specs}
494
+
495
+ is_discrete = bool(getattr(config, "discrete_actions", None))
496
+
497
+ inputs = build_input_bindings(
498
+ list(unwrapped._obs_nodes),
499
+ descriptions=desc_map,
500
+ obs_stats=obs_stats,
501
+ obs_norm_minmax=obs_norm_minmax,
502
+ obs_range=obs_range,
503
+ )
504
+ outputs = build_output_bindings(
505
+ list(unwrapped._action_nodes),
506
+ unwrapped._action_ranges,
507
+ descriptions=desc_map,
508
+ discrete=is_discrete,
509
+ )
510
+
511
+ # Detect algorithm from best_model if available
512
+ algo = config.algorithm
513
+ best_model = config.run_dir / "best_model.zip"
514
+ if best_model.exists():
515
+ algo = detect_algorithm(best_model)
516
+
517
+ training = TrainingMeta(
518
+ scenario_name=config.scenario_name,
519
+ algorithm=algo,
520
+ obs_depth=config.obs_depth,
521
+ sample_rate=config.dt,
522
+ model_type="POSITIONAL",
523
+ )
524
+ if is_discrete:
525
+ training.output_mode = "DISCRETE"
526
+ action_map = getattr(config, "action_map", None)
527
+ if action_map:
528
+ training.action_map = action_map
529
+
530
+ env.close()
531
+ return inputs, outputs, training
532
+
533
+
534
+ # ---------------------------------------------------------------------------
535
+ # CLI
536
+ # ---------------------------------------------------------------------------
537
+
538
+
539
+ def main() -> None:
540
+ parser = argparse.ArgumentParser(
541
+ description="Export an SB3 model to ONNX for the Koios predict engine."
542
+ )
543
+ parser.add_argument(
544
+ "model",
545
+ help="Path to saved SB3 model .zip file.",
546
+ )
547
+ parser.add_argument(
548
+ "--output",
549
+ "-o",
550
+ help="Output .onnx file path. Defaults to same directory with .onnx extension.",
551
+ )
552
+ parser.add_argument(
553
+ "--config",
554
+ "-c",
555
+ help="Path to config YAML for action node metadata.",
556
+ )
557
+ parser.add_argument(
558
+ "--opset",
559
+ type=int,
560
+ default=18,
561
+ help="ONNX opset version (default: 18).",
562
+ )
563
+ parser.add_argument(
564
+ "--no-verify",
565
+ action="store_true",
566
+ help="Skip output verification against SB3 predict.",
567
+ )
568
+ parser.add_argument(
569
+ "--verify-samples",
570
+ type=int,
571
+ default=100,
572
+ help="Number of random samples for verification (default: 100).",
573
+ )
574
+ args = parser.parse_args()
575
+
576
+ model_path = Path(args.model)
577
+ if not model_path.exists():
578
+ model_path = model_path.with_suffix(".zip")
579
+ if not model_path.exists():
580
+ print(f"Model not found: {args.model}")
581
+ sys.exit(1)
582
+
583
+ # Auto-detect config from model's directory if not specified
584
+ config_path = args.config
585
+ if config_path is None:
586
+ candidate = model_path.parent / "config.yaml"
587
+ if candidate.exists():
588
+ config_path = str(candidate)
589
+
590
+ # Build bindings from config (requires koios-scenario-simulator)
591
+ inputs_list: list[InputBinding] | None = None
592
+ outputs_list: list[OutputBinding] | None = None
593
+ training_meta: TrainingMeta | None = None
594
+
595
+ if config_path:
596
+ try:
597
+ from sim_training.config import load_config as _load_config
598
+
599
+ config = _load_config(config_path)
600
+
601
+ # Load VecNormalize stats (z_score)
602
+ obs_stats_np: tuple[np.ndarray, np.ndarray, float, float] | None = None
603
+ stats_file = model_path.parent / "vec_normalize.pkl"
604
+ if stats_file.exists():
605
+ stats = load_vec_normalize_stats(stats_file)
606
+ if stats is not None:
607
+ mean_np, var_np, clip = stats
608
+ obs_stats_np = (mean_np, var_np, clip, 1e-8)
609
+
610
+ # Load min_max normalization bounds
611
+ obs_norm_minmax_data: tuple[np.ndarray, np.ndarray] | None = None
612
+ minmax_file = model_path.parent / "minmax_normalize.json"
613
+ if minmax_file.exists():
614
+ with open(minmax_file) as f:
615
+ minmax_json = json.load(f)
616
+ obs_norm_minmax_data = (
617
+ np.array(minmax_json["obs_mins"], dtype=np.float32),
618
+ np.array(minmax_json["obs_maxs"], dtype=np.float32),
619
+ )
620
+ print(f"Loaded min_max normalization from {minmax_file}")
621
+
622
+ # Load observation ranges (failure bounds)
623
+ obs_range_data: tuple[np.ndarray, np.ndarray] | None = None
624
+ obs_range_path = model_path.parent / "obs_range.json"
625
+ if obs_range_path.exists():
626
+ with open(obs_range_path) as f:
627
+ range_json = json.load(f)
628
+ obs_range_data = (
629
+ np.array(range_json["obs_min"], dtype=np.float32),
630
+ np.array(range_json["obs_max"], dtype=np.float32),
631
+ )
632
+ print(f"Loaded observation ranges from {obs_range_path}")
633
+
634
+ inputs_list, outputs_list, training_meta = _bindings_from_config(
635
+ config,
636
+ model_path,
637
+ obs_stats=obs_stats_np,
638
+ obs_norm_minmax=obs_norm_minmax_data,
639
+ obs_range=obs_range_data,
640
+ )
641
+ except ImportError:
642
+ print("WARNING: sim_training not available — skipping metadata embedding.")
643
+ print("Pass explicit bindings via the Python API instead.")
644
+
645
+ try:
646
+ export_onnx(
647
+ model_path,
648
+ output_path=args.output,
649
+ inputs=inputs_list,
650
+ outputs=outputs_list,
651
+ training=training_meta,
652
+ opset_version=args.opset,
653
+ verify=not args.no_verify,
654
+ num_verify_samples=args.verify_samples,
655
+ )
656
+ except KeyboardInterrupt:
657
+ print("\nExport interrupted.")
658
+ sys.exit(1)
659
+
660
+
661
+ if __name__ == "__main__":
662
+ main()
@@ -0,0 +1,299 @@
1
+ """Koios ONNX metadata embedding — no torch/SB3 dependency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from datetime import UTC, datetime
8
+
9
+ import numpy as np
10
+ import onnx
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Dataclasses
14
+ # ---------------------------------------------------------------------------
15
+
16
+
17
+ @dataclass
18
+ class InputBinding:
19
+ """Describes one input (observation) binding for the Koios predict engine."""
20
+
21
+ name: str
22
+ description: str = ""
23
+ normalization_type: str = "none"
24
+ normalization_source: str | None = None
25
+ custom_mean: float | None = None
26
+ custom_std: float | None = None
27
+ custom_minimum: float | None = None # for min_max normalization
28
+ custom_maximum: float | None = None # for min_max normalization
29
+ custom_failure: bool = False
30
+ custom_failure_minimum: float | None = None
31
+ custom_failure_maximum: float | None = None
32
+
33
+
34
+ @dataclass
35
+ class OutputBinding:
36
+ """Describes one output (action) binding for the Koios predict engine."""
37
+
38
+ name: str
39
+ description: str = ""
40
+ range_min: float | None = None
41
+ range_max: float | None = None
42
+ normalization_type: str = "none"
43
+ normalization_source: str | None = None
44
+ custom_minimum: float | None = None
45
+ custom_maximum: float | None = None
46
+ clamp_output: bool = False
47
+
48
+
49
+ @dataclass
50
+ class TrainingMeta:
51
+ """Model-level training metadata for the Koios predict engine."""
52
+
53
+ scenario_name: str = ""
54
+ algorithm: str = ""
55
+ obs_depth: int = 1
56
+ sample_rate: float = 1.0
57
+ model_type: str = "POSITIONAL"
58
+ output_mode: str | None = None
59
+ action_map: list[dict] | None = None
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Core function
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def embed_koios_metadata(
68
+ onnx_model: onnx.ModelProto,
69
+ *,
70
+ inputs: list[InputBinding],
71
+ outputs: list[OutputBinding],
72
+ training: TrainingMeta | None = None,
73
+ ) -> None:
74
+ """Embed ``koios.training`` and ``koios.bindings`` metadata into an ONNX model (in-place).
75
+
76
+ Parameters
77
+ ----------
78
+ onnx_model:
79
+ Loaded ONNX model proto (modified in-place).
80
+ inputs:
81
+ Input binding definitions (one per observation feature).
82
+ outputs:
83
+ Output binding definitions (one per action).
84
+ training:
85
+ Optional model-level training metadata.
86
+ """
87
+ # koios.training
88
+ if training is None:
89
+ training = TrainingMeta()
90
+
91
+ training_meta: dict[str, object] = {
92
+ "schema_version": 1,
93
+ "scenario_name": training.scenario_name,
94
+ "algorithm": training.algorithm,
95
+ "obs_depth": training.obs_depth,
96
+ "sample_rate": training.sample_rate,
97
+ "model_type": training.model_type,
98
+ "exported_at": datetime.now(UTC).isoformat(),
99
+ }
100
+ if training.output_mode is not None:
101
+ training_meta["output_mode"] = training.output_mode
102
+ if training.action_map is not None:
103
+ training_meta["action_map"] = training.action_map
104
+
105
+ # koios.bindings
106
+ input_dicts = []
107
+ for i, inp in enumerate(inputs):
108
+ entry: dict[str, object] = {
109
+ "binding_order": i + 1,
110
+ "name": inp.name,
111
+ "description": inp.description,
112
+ }
113
+ if inp.normalization_type != "none":
114
+ entry["normalization_type"] = inp.normalization_type
115
+ if inp.normalization_source is not None:
116
+ entry["normalization_source"] = inp.normalization_source
117
+ if inp.custom_mean is not None:
118
+ entry["custom_mean"] = inp.custom_mean
119
+ if inp.custom_std is not None:
120
+ entry["custom_std"] = inp.custom_std
121
+ if inp.custom_minimum is not None:
122
+ entry["custom_minimum"] = inp.custom_minimum
123
+ if inp.custom_maximum is not None:
124
+ entry["custom_maximum"] = inp.custom_maximum
125
+ if inp.custom_failure:
126
+ entry["custom_failure"] = True
127
+ if inp.custom_failure_minimum is not None:
128
+ entry["custom_failure_minimum"] = inp.custom_failure_minimum
129
+ if inp.custom_failure_maximum is not None:
130
+ entry["custom_failure_maximum"] = inp.custom_failure_maximum
131
+ input_dicts.append(entry)
132
+
133
+ output_dicts = []
134
+ for i, out in enumerate(outputs):
135
+ entry = {
136
+ "binding_order": i + 1,
137
+ "name": out.name,
138
+ "description": out.description,
139
+ "normalization_type": out.normalization_type,
140
+ }
141
+ if out.range_min is not None:
142
+ entry["range_min"] = out.range_min
143
+ if out.range_max is not None:
144
+ entry["range_max"] = out.range_max
145
+ if out.normalization_source is not None:
146
+ entry["normalization_source"] = out.normalization_source
147
+ if out.custom_minimum is not None:
148
+ entry["custom_minimum"] = out.custom_minimum
149
+ if out.custom_maximum is not None:
150
+ entry["custom_maximum"] = out.custom_maximum
151
+ if out.clamp_output:
152
+ entry["clamp_output"] = True
153
+ output_dicts.append(entry)
154
+
155
+ bindings_meta: dict[str, object] = {
156
+ "schema_version": 1,
157
+ "output_denormalized": False,
158
+ "obs_normalized": False,
159
+ "inputs": input_dicts,
160
+ "outputs": output_dicts,
161
+ }
162
+
163
+ onnx_model.metadata_props.append(
164
+ onnx.StringStringEntryProto(key="koios.training", value=json.dumps(training_meta))
165
+ )
166
+ onnx_model.metadata_props.append(
167
+ onnx.StringStringEntryProto(key="koios.bindings", value=json.dumps(bindings_meta))
168
+ )
169
+
170
+ print(f"Embedded koios.training metadata ({len(inputs)} inputs, {len(outputs)} outputs)")
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Convenience builders
175
+ # ---------------------------------------------------------------------------
176
+
177
+
178
+ def build_input_bindings(
179
+ obs_nodes: list[str],
180
+ *,
181
+ descriptions: dict[str, str] | None = None,
182
+ obs_stats: tuple[np.ndarray, np.ndarray, float, float] | None = None,
183
+ obs_norm_minmax: tuple[np.ndarray, np.ndarray] | None = None,
184
+ obs_range: tuple[np.ndarray, np.ndarray] | None = None,
185
+ ) -> list[InputBinding]:
186
+ """Build InputBinding list from node names + optional normalization stats.
187
+
188
+ Parameters
189
+ ----------
190
+ obs_nodes:
191
+ Observation node names (one per feature).
192
+ descriptions:
193
+ Optional ``{node_name: description}`` mapping.
194
+ obs_stats:
195
+ Optional ``(mean, var, clip, epsilon)`` from VecNormalize. When
196
+ provided, Z-Score normalization parameters are computed per feature.
197
+ If the stats have shape ``(obs_depth, num_features)``, they are
198
+ averaged across depth positions.
199
+ obs_norm_minmax:
200
+ Optional ``(obs_mins, obs_maxs)`` per-feature arrays for min/max
201
+ normalization. When provided (and ``obs_stats`` is not), sets
202
+ ``normalization_type="min_max"`` with ``custom_minimum``/``custom_maximum``.
203
+ obs_range:
204
+ Optional ``(obs_min, obs_max)`` per-feature arrays from training.
205
+ When provided, failure bounds are set per input binding.
206
+ """
207
+ desc = descriptions or {}
208
+ num_features = len(obs_nodes)
209
+
210
+ # Compute per-feature mean/std from VecNormalize stats
211
+ feature_mean: np.ndarray | None = None
212
+ feature_std: np.ndarray | None = None
213
+ if obs_stats is not None:
214
+ mean_np, var_np, _clip, epsilon = obs_stats
215
+ if mean_np.ndim == 2:
216
+ feature_mean = mean_np.mean(axis=0)
217
+ feature_std = np.sqrt(var_np.mean(axis=0) + epsilon)
218
+ elif mean_np.size > num_features:
219
+ obs_depth = mean_np.size // num_features
220
+ mean_2d = mean_np.reshape(obs_depth, num_features)
221
+ var_2d = var_np.reshape(obs_depth, num_features)
222
+ feature_mean = mean_2d.mean(axis=0)
223
+ feature_std = np.sqrt(var_2d.mean(axis=0) + epsilon)
224
+ else:
225
+ feature_mean = mean_np.ravel()
226
+ feature_std = np.sqrt(var_np.ravel() + epsilon)
227
+
228
+ bindings = []
229
+ for i, name in enumerate(obs_nodes):
230
+ b = InputBinding(name=name, description=desc.get(name, ""))
231
+ if feature_mean is not None and feature_std is not None:
232
+ b.normalization_type = "z_score"
233
+ b.normalization_source = "custom"
234
+ b.custom_mean = float(feature_mean[i])
235
+ b.custom_std = float(feature_std[i])
236
+ elif obs_norm_minmax is not None:
237
+ obs_mins, obs_maxs = obs_norm_minmax
238
+ b.normalization_type = "min_max"
239
+ b.normalization_source = "custom"
240
+ b.custom_minimum = float(obs_mins[i])
241
+ b.custom_maximum = float(obs_maxs[i])
242
+ if obs_range is not None:
243
+ obs_min, obs_max = obs_range
244
+ b.custom_failure = True
245
+ b.custom_failure_minimum = float(obs_min[i])
246
+ b.custom_failure_maximum = float(obs_max[i])
247
+ bindings.append(b)
248
+
249
+ return bindings
250
+
251
+
252
+ def build_output_bindings(
253
+ action_nodes: list[str],
254
+ action_ranges: dict[str, tuple[float, float]],
255
+ *,
256
+ descriptions: dict[str, str] | None = None,
257
+ discrete: bool = False,
258
+ ) -> list[OutputBinding]:
259
+ """Build OutputBinding list from action node names + ranges.
260
+
261
+ Parameters
262
+ ----------
263
+ action_nodes:
264
+ Action node names.
265
+ action_ranges:
266
+ ``{node_name: (low, high)}`` action space bounds.
267
+ descriptions:
268
+ Optional ``{node_name: description}`` mapping.
269
+ discrete:
270
+ If True, outputs get ``normalization_type="none"`` (DQN models).
271
+ """
272
+ desc = descriptions or {}
273
+ bindings = []
274
+ for name in action_nodes:
275
+ if discrete:
276
+ bindings.append(
277
+ OutputBinding(
278
+ name=name,
279
+ description=desc.get(name, ""),
280
+ normalization_type="none",
281
+ )
282
+ )
283
+ else:
284
+ lo, hi = action_ranges[name]
285
+ bindings.append(
286
+ OutputBinding(
287
+ name=name,
288
+ description=desc.get(name, ""),
289
+ range_min=lo,
290
+ range_max=hi,
291
+ normalization_type="symmetric",
292
+ normalization_source="custom",
293
+ custom_minimum=lo,
294
+ custom_maximum=hi,
295
+ clamp_output=True,
296
+ )
297
+ )
298
+
299
+ return bindings
File without changes
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.4
2
+ Name: koios-model-utils
3
+ Version: 1.0.0rc1
4
+ Summary: Utilities for exporting and annotating ONNX models for the Koios IoT platform
5
+ Project-URL: Homepage, https://www.ai-op.com
6
+ Project-URL: Documentation, https://github.com/Ai-Ops-Inc/koios-model-utils
7
+ Project-URL: Repository, https://github.com/Ai-Ops-Inc/koios-model-utils
8
+ Project-URL: Issues, https://github.com/Ai-Ops-Inc/koios-model-utils/issues
9
+ Author-email: "Ai-OPs, Inc." <support@ai-op.com>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: industrial,iot,koios,machine-learning,onnx,reinforcement-learning
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: numpy
25
+ Requires-Dist: onnx>=1.14
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.4.0; extra == 'dev'
29
+ Provides-Extra: sb3
30
+ Requires-Dist: onnxruntime>=1.16; extra == 'sb3'
31
+ Requires-Dist: onnxscript; extra == 'sb3'
32
+ Requires-Dist: stable-baselines3>=2.0; extra == 'sb3'
33
+ Requires-Dist: torch; extra == 'sb3'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # koios-model-utils
37
+
38
+ Utilities for exporting and annotating ONNX models for the [Koios](https://ai-op.com) IoT platform.
39
+
40
+ This package helps data scientists prepare trained ML models (especially Stable-Baselines3 reinforcement learning models) for deployment on Koios. It handles ONNX export, metadata embedding, and normalization parameter annotation so the Koios Predict Engine can run inference correctly.
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ # Metadata embedding only (no torch/SB3 dependency)
46
+ pip install koios-model-utils
47
+
48
+ # Full SB3 export pipeline
49
+ pip install koios-model-utils[sb3]
50
+ ```
51
+
52
+ ## Quick Start
53
+
54
+ ### Embed metadata into an existing ONNX model
55
+
56
+ ```python
57
+ import onnx
58
+ from koios_model_utils import (
59
+ InputBinding, OutputBinding, TrainingMeta,
60
+ embed_koios_metadata,
61
+ )
62
+
63
+ model = onnx.load("my_model.onnx")
64
+
65
+ inputs = [
66
+ InputBinding(name="temperature", description="Tank temperature (C)"),
67
+ InputBinding(name="pressure", description="Vessel pressure (kPa)"),
68
+ ]
69
+ outputs = [
70
+ OutputBinding(
71
+ name="valve_position",
72
+ range_min=0.0, range_max=100.0,
73
+ normalization_type="symmetric",
74
+ normalization_source="custom",
75
+ custom_minimum=0.0, custom_maximum=100.0,
76
+ clamp_output=True,
77
+ ),
78
+ ]
79
+ training = TrainingMeta(
80
+ scenario_name="tank_temperature",
81
+ algorithm="PPO",
82
+ obs_depth=5,
83
+ sample_rate=1.0,
84
+ )
85
+
86
+ embed_koios_metadata(model, inputs=inputs, outputs=outputs, training=training)
87
+ onnx.save(model, "my_model_koios.onnx")
88
+ ```
89
+
90
+ ### Export an SB3 model to ONNX
91
+
92
+ ```python
93
+ from koios_model_utils import export_onnx, build_input_bindings, build_output_bindings, TrainingMeta
94
+
95
+ inputs = build_input_bindings(
96
+ ["temperature", "level", "flow_rate"],
97
+ descriptions={"temperature": "Tank temp (C)", "level": "Tank level (%)"},
98
+ )
99
+ outputs = build_output_bindings(
100
+ ["valve_position"],
101
+ action_ranges={"valve_position": (0.0, 100.0)},
102
+ )
103
+
104
+ export_onnx(
105
+ "runs/best_model.zip",
106
+ inputs=inputs,
107
+ outputs=outputs,
108
+ training=TrainingMeta(scenario_name="tank_temperature", algorithm="PPO", obs_depth=5),
109
+ )
110
+ ```
111
+
112
+ ### CLI
113
+
114
+ ```bash
115
+ # Export with auto-detected settings
116
+ koios-export runs/best_model.zip
117
+
118
+ # Specify output path and opset version
119
+ koios-export runs/best_model.zip --output model.onnx --opset 18
120
+ ```
121
+
122
+ ## SB3 Training Callbacks
123
+
124
+ ```python
125
+ from koios_model_utils import ObsRangeCallback, VecNormalizeSyncCallback
126
+
127
+ # Track observation ranges during training
128
+ obs_range_cb = ObsRangeCallback(run_dir, verbose=1)
129
+
130
+ # Sync VecNormalize stats with best model checkpoints
131
+ vec_sync_cb = VecNormalizeSyncCallback(eval_callback, verbose=1)
132
+ ```
133
+
134
+ ## API Reference
135
+
136
+ ### Metadata Types
137
+
138
+ | Class | Description |
139
+ |-------|-------------|
140
+ | `InputBinding` | Describes one input (observation) binding — name, normalization, failure bounds |
141
+ | `OutputBinding` | Describes one output (action) binding — name, range, normalization, clamping |
142
+ | `TrainingMeta` | Model-level metadata — algorithm, obs_depth, sample_rate, model_type |
143
+
144
+ ### Functions
145
+
146
+ | Function | Description |
147
+ |----------|-------------|
148
+ | `embed_koios_metadata()` | Embed `koios.training` and `koios.bindings` metadata into an ONNX model |
149
+ | `build_input_bindings()` | Build input bindings from node names + optional normalization stats |
150
+ | `build_output_bindings()` | Build output bindings from action names + ranges |
151
+ | `export_onnx()` | Full SB3-to-ONNX export pipeline (requires `[sb3]` extra) |
152
+ | `detect_algorithm()` | Detect SB3 algorithm (PPO, SAC, TD3, etc.) from a saved `.zip` model |
153
+
154
+ ### Callbacks (requires `[sb3]` extra)
155
+
156
+ | Callback | Description |
157
+ |----------|-------------|
158
+ | `ObsRangeCallback` | Track per-feature min/max of observations during training |
159
+ | `VecNormalizeSyncCallback` | Save VecNormalize stats alongside best model checkpoints |
160
+
161
+ ## Development
162
+
163
+ ```bash
164
+ # Install with all dependencies
165
+ pip install -e ".[sb3,dev]"
166
+
167
+ # Run tests
168
+ make test
169
+
170
+ # Lint
171
+ make lint
172
+ ```
173
+
174
+ ## License
175
+
176
+ MIT License - see [LICENSE](LICENSE) for details.
@@ -0,0 +1,10 @@
1
+ koios_model_utils/__init__.py,sha256=cIXywPWyjiPOXZdIG15FSRA6QoK6TJJTaYQ0uDFk4Jk,1152
2
+ koios_model_utils/callbacks.py,sha256=v9teVKDjhSZLIPVK9blqBPp56u1mL0F4VGZ7VOo-GQM,5556
3
+ koios_model_utils/export.py,sha256=dg8U587MThA-YtT_kItq_QhJTL26viRPfVjR80wBF68,24221
4
+ koios_model_utils/metadata.py,sha256=Y_oIUmH42SVju7M3PGbXwZb2Coh5pqfE0z-3axXQxNU,10518
5
+ koios_model_utils/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ koios_model_utils-1.0.0rc1.dist-info/METADATA,sha256=Iv7CwRhnSNHmj8PbQ6EzHaddMswBclnQkOgHgkIL4EQ,5471
7
+ koios_model_utils-1.0.0rc1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ koios_model_utils-1.0.0rc1.dist-info/entry_points.txt,sha256=qWYPe_ewlenHyXtl9WDhUbU8QfkHYjPOqPZRDi9BgEc,63
9
+ koios_model_utils-1.0.0rc1.dist-info/licenses/LICENSE,sha256=gLwtrbMu-rRAs0ddzfXVcJm9VvgFiowC_IKcSBt00kE,1074
10
+ koios_model_utils-1.0.0rc1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ koios-export = koios_model_utils.export:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 Ai-OPs, Inc.
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.