koios-model-utils 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- koios_model_utils/__init__.py +60 -0
- koios_model_utils/callbacks.py +147 -0
- koios_model_utils/enums.py +67 -0
- koios_model_utils/export.py +662 -0
- koios_model_utils/metadata.py +338 -0
- koios_model_utils/py.typed +0 -0
- koios_model_utils-1.0.0.dist-info/METADATA +187 -0
- koios_model_utils-1.0.0.dist-info/RECORD +11 -0
- koios_model_utils-1.0.0.dist-info/WHEEL +4 -0
- koios_model_utils-1.0.0.dist-info/entry_points.txt +2 -0
- koios_model_utils-1.0.0.dist-info/licenses/LICENSE +203 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""koios-model-utils — utilities for exporting and annotating ONNX models for the Koios IoT platform."""
|
|
2
|
+
|
|
3
|
+
__version__ = "1.0.0"
|
|
4
|
+
|
|
5
|
+
from koios_model_utils.enums import (
|
|
6
|
+
Algorithm,
|
|
7
|
+
ModelType,
|
|
8
|
+
NormalizationSource,
|
|
9
|
+
NormalizationType,
|
|
10
|
+
OutputMode,
|
|
11
|
+
)
|
|
12
|
+
from koios_model_utils.metadata import (
|
|
13
|
+
InputBinding,
|
|
14
|
+
OutputBinding,
|
|
15
|
+
TrainingMeta,
|
|
16
|
+
build_input_bindings,
|
|
17
|
+
build_output_bindings,
|
|
18
|
+
embed_koios_metadata,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# SB3-dependent exports — only available with koios-model-utils[sb3]
|
|
22
|
+
try:
|
|
23
|
+
from koios_model_utils.callbacks import (
|
|
24
|
+
ObsRangeCallback,
|
|
25
|
+
VecNormalizeSyncCallback,
|
|
26
|
+
)
|
|
27
|
+
from koios_model_utils.export import (
|
|
28
|
+
OnnxActorWrapper,
|
|
29
|
+
OnnxDQNWrapper,
|
|
30
|
+
detect_algorithm,
|
|
31
|
+
export_onnx,
|
|
32
|
+
load_vec_normalize_stats,
|
|
33
|
+
)
|
|
34
|
+
except ImportError:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
# enums (always available)
|
|
39
|
+
"Algorithm",
|
|
40
|
+
"ModelType",
|
|
41
|
+
"NormalizationSource",
|
|
42
|
+
"NormalizationType",
|
|
43
|
+
"OutputMode",
|
|
44
|
+
# metadata (always available)
|
|
45
|
+
"InputBinding",
|
|
46
|
+
"OutputBinding",
|
|
47
|
+
"TrainingMeta",
|
|
48
|
+
"embed_koios_metadata",
|
|
49
|
+
"build_input_bindings",
|
|
50
|
+
"build_output_bindings",
|
|
51
|
+
# export (requires sb3 extra)
|
|
52
|
+
"export_onnx",
|
|
53
|
+
"detect_algorithm",
|
|
54
|
+
"load_vec_normalize_stats",
|
|
55
|
+
"OnnxActorWrapper",
|
|
56
|
+
"OnnxDQNWrapper",
|
|
57
|
+
# callbacks (requires sb3 extra)
|
|
58
|
+
"ObsRangeCallback",
|
|
59
|
+
"VecNormalizeSyncCallback",
|
|
60
|
+
]
|
|
@@ -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,67 @@
|
|
|
1
|
+
"""String enums for Koios metadata values.
|
|
2
|
+
|
|
3
|
+
Every enum here is a :class:`enum.StrEnum` — members are real strings and
|
|
4
|
+
serialize transparently through ``json.dumps``, so users may pass either
|
|
5
|
+
the enum member or the raw string literal wherever metadata values are
|
|
6
|
+
expected. The server consumes strings (case-insensitive for
|
|
7
|
+
normalization keys, exact-match for training keys), so both forms flow
|
|
8
|
+
through identically.
|
|
9
|
+
|
|
10
|
+
The string values match the names of the corresponding Django
|
|
11
|
+
``IntegerChoices`` members in
|
|
12
|
+
``koios-webapp-py/app/AiOpsInterface/apps/aiops/choices.py`` — renaming
|
|
13
|
+
a member on the server requires updating the matching member here (the
|
|
14
|
+
contract tests in ``tests/test_metadata.py`` pin this).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from enum import StrEnum
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NormalizationType(StrEnum):
|
|
23
|
+
"""How a binding's value is mapped into / out of model space."""
|
|
24
|
+
|
|
25
|
+
NONE = "none"
|
|
26
|
+
MIN_MAX = "min_max"
|
|
27
|
+
SYMMETRIC = "symmetric"
|
|
28
|
+
Z_SCORE = "z_score"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NormalizationSource(StrEnum):
|
|
32
|
+
"""Where normalization parameters come from at runtime."""
|
|
33
|
+
|
|
34
|
+
# The tag's engineering range (range_min / range_max on the bound tag).
|
|
35
|
+
TAG_RANGE = "tag_range"
|
|
36
|
+
# Explicit ``custom_minimum`` / ``custom_maximum`` / ``custom_mean`` /
|
|
37
|
+
# ``custom_std`` values embedded in the ONNX metadata.
|
|
38
|
+
CUSTOM = "custom"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ModelType(StrEnum):
|
|
42
|
+
"""How the predict engine applies the model's output to the bound tag.
|
|
43
|
+
|
|
44
|
+
Maps to the server's ``OutputApplicationChoices``. The legacy
|
|
45
|
+
``POSITIONAL`` / ``VELOCITY`` aliases are still accepted by the
|
|
46
|
+
server but should not be used in new code.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
ABSOLUTE = "ABSOLUTE"
|
|
50
|
+
RELATIVE = "RELATIVE"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OutputMode(StrEnum):
|
|
54
|
+
"""Whether the model emits a continuous value or a categorical action."""
|
|
55
|
+
|
|
56
|
+
CONTINUOUS = "CONTINUOUS"
|
|
57
|
+
DISCRETE = "DISCRETE"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Algorithm(StrEnum):
|
|
61
|
+
"""Stable-Baselines3 algorithm identifiers recognized by the exporter."""
|
|
62
|
+
|
|
63
|
+
PPO = "PPO"
|
|
64
|
+
SAC = "SAC"
|
|
65
|
+
TD3 = "TD3"
|
|
66
|
+
A2C = "A2C"
|
|
67
|
+
DQN = "DQN"
|