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.
- koios_model_utils/__init__.py +47 -0
- koios_model_utils/callbacks.py +147 -0
- koios_model_utils/export.py +662 -0
- koios_model_utils/metadata.py +299 -0
- koios_model_utils/py.typed +0 -0
- koios_model_utils-1.0.0rc1.dist-info/METADATA +176 -0
- koios_model_utils-1.0.0rc1.dist-info/RECORD +10 -0
- koios_model_utils-1.0.0rc1.dist-info/WHEEL +4 -0
- koios_model_utils-1.0.0rc1.dist-info/entry_points.txt +2 -0
- koios_model_utils-1.0.0rc1.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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.
|