humalab 0.0.1__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.

Potentially problematic release.


This version of humalab might be problematic. Click here for more details.

@@ -0,0 +1,54 @@
1
+
2
+ from humalab_sdk.metrics.metric import MetricGranularity, Metrics, MetricType
3
+ from humalab_sdk.constants import EpisodeStatus
4
+
5
+
6
+ class Summary(Metrics):
7
+ def __init__(self,
8
+ name: str,
9
+ summary: str,
10
+ episode_id: str,
11
+ run_id: str,
12
+ granularity: MetricGranularity = MetricGranularity.RUN,
13
+ ) -> None:
14
+ """
15
+ A summary metric that captures a single value per episode or run.
16
+
17
+ Args:
18
+ name (str): The name of the metric.
19
+ summary (str | None): Specify aggregate metrics added to summary.
20
+ Supported aggregations include "min", "max", "mean", "last",
21
+ "first", and "none". "none" prevents a summary
22
+ from being generated.
23
+ granularity (MetricGranularity): The granularity of the metric.
24
+ """
25
+ if granularity == MetricGranularity.RUN:
26
+ raise ValueError("Summary metrics cannot have RUN granularity.")
27
+ if summary not in {"min", "max", "mean", "last", "first", "none"}:
28
+ raise ValueError(f"Unsupported summary type: {summary}. Supported types are 'min', 'max', 'mean', 'last', 'first', and 'none'.")
29
+ super().__init__(name, MetricType.SUMMARY, episode_id=episode_id, run_id=run_id, granularity=granularity)
30
+ self.summary = summary
31
+
32
+ def _submit(self) -> None:
33
+ if not self._values:
34
+ return
35
+ # For summary metrics, we only keep the latest value
36
+ if self.summary == "last":
37
+ self._values = [self._values[-1]]
38
+ elif self.summary == "first":
39
+ self._values = [self._values[0]]
40
+ elif self.summary == "none":
41
+ self._values = []
42
+ elif self.summary in {"min", "max", "mean"}:
43
+ if not self._values:
44
+ self._values = []
45
+ else:
46
+ if self.summary == "min":
47
+ agg_value = min(self._values)
48
+ elif self.summary == "max":
49
+ agg_value = max(self._values)
50
+ elif self.summary == "mean":
51
+ agg_value = sum(self._values) / len(self._values)
52
+ self._values = [agg_value]
53
+
54
+ super()._submit()
humalab/run.py ADDED
@@ -0,0 +1,214 @@
1
+ import uuid
2
+ from humalab_sdk.metrics.dist_metric import DistributionMetric
3
+ from humalab_sdk.metrics.metric import MetricGranularity, MetricType, Metrics
4
+ from humalab_sdk.constants import EpisodeStatus
5
+
6
+ from humalab_sdk.metrics.summary import Summary
7
+ from humalab_sdk.scenario import Scenario
8
+
9
+ class Run:
10
+ def __init__(self,
11
+ entity: str,
12
+ project: str,
13
+ scenario: Scenario,
14
+ name: str | None = None,
15
+ description: str | None = None,
16
+ id: str | None = None,
17
+ tags: list[str] | None = None,
18
+ ) -> None:
19
+ """
20
+ Initialize a new Run instance.
21
+
22
+ Args:
23
+ entity (str): The entity (user or team) under which the run is created.
24
+ project (str): The project name under which the run is created.
25
+ scenario (Scenario): The scenario instance for the run.
26
+ name (str | None): The name of the run.
27
+ description (str | None): A description of the run.
28
+ id (str | None): The unique identifier for the run. If None, a UUID is generated.
29
+ tags (list[str] | None): A list of tags associated with the run.
30
+ """
31
+ self._entity = entity
32
+ self._project = project
33
+ self._id = id or str(uuid.uuid4())
34
+ self._name = name or ""
35
+ self._description = description or ""
36
+ self._tags = tags or []
37
+ self._finished = False
38
+
39
+ self._episode = str(uuid.uuid4())
40
+
41
+ self._scenario = scenario
42
+
43
+ self._metrics = {}
44
+
45
+ @property
46
+ def entity(self) -> str:
47
+ """The entity (user or team) under which the run is created.
48
+
49
+ Returns:
50
+ str: The entity name.
51
+ """
52
+ return self._entity
53
+
54
+ @property
55
+ def project(self) -> str:
56
+ """The project name under which the run is created.
57
+
58
+ Returns:
59
+ str: The project name.
60
+ """
61
+ return self._project
62
+
63
+ @property
64
+ def id(self) -> str:
65
+ """The unique identifier for the run.
66
+
67
+ Returns:
68
+ str: The run ID.
69
+ """
70
+ return self._id
71
+
72
+ @property
73
+ def name(self) -> str:
74
+ """The name of the run.
75
+
76
+ Returns:
77
+ str: The run name.
78
+ """
79
+ return self._name
80
+
81
+ @property
82
+ def description(self) -> str:
83
+ """The description of the run.
84
+
85
+ Returns:
86
+ str: The run description.
87
+ """
88
+ return self._description
89
+
90
+ @property
91
+ def tags(self) -> list[str]:
92
+ """The tags associated with the run.
93
+
94
+ Returns:
95
+ list[str]: The list of tags.
96
+ """
97
+ return self._tags
98
+
99
+ @property
100
+ def episode(self) -> str:
101
+ """The episode ID for the run.
102
+
103
+ Returns:
104
+ str: The episode ID.
105
+ """
106
+ return self._episode
107
+
108
+ @property
109
+ def scenario(self) -> Scenario:
110
+ """The scenario associated with the run.
111
+
112
+ Returns:
113
+ Scenario: The scenario instance.
114
+ """
115
+ return self._scenario
116
+
117
+ def finish(self,
118
+ status: EpisodeStatus = EpisodeStatus.PASS,
119
+ quiet: bool | None = None) -> None:
120
+ """Finish the run and submit final metrics.
121
+
122
+ Args:
123
+ status (EpisodeStatus): The final status of the episode.
124
+ quiet (bool | None): Whether to suppress output.
125
+ """
126
+ self._finished = True
127
+ self._scenario.finish()
128
+ for metric in self._metrics.values():
129
+ metric.finish(status=status)
130
+
131
+ def log(self,
132
+ data: dict,
133
+ step: int | None = None,
134
+ commit: bool = True,
135
+ ) -> None:
136
+ """Log metrics for the run.
137
+
138
+ Args:
139
+ data (dict): A dictionary of metric names and their values.
140
+ step (int | None): The step number for the metrics.
141
+ commit (bool): Whether to commit the metrics immediately.
142
+ """
143
+ for key, value in data.items():
144
+ if key in self._metrics:
145
+ metric = self._metrics[key]
146
+ metric.log(value, step=step, commit=commit)
147
+ else:
148
+ self._metrics[key] = Metrics(key,
149
+ metric_type=MetricType.DEFAULT,
150
+ run_id=self._id,
151
+ granularity=MetricGranularity.EPISODE,
152
+ episode_id=self._episode)
153
+ self._metrics[key].log(value, step=step, commit=commit)
154
+
155
+ def reset(self, status: EpisodeStatus = EpisodeStatus.PASS) -> None:
156
+ """Reset the run for a new episode.
157
+
158
+ Args:
159
+ status (EpisodeStatus): The status of the current episode before reset.
160
+ """
161
+ self._submit_episode_status(status=status, episode=self._episode)
162
+ self._episode = str(uuid.uuid4())
163
+ self._finished = False
164
+ self._scenario.reset(episode_id=self._episode)
165
+ for metric in self._metrics.values():
166
+ metric.reset(episode=self._episode)
167
+
168
+ def _submit_episode_status(self, status: EpisodeStatus, episode: str) -> None:
169
+ # TODO: Implement submission of episode status
170
+ pass
171
+
172
+ def define_metric(self,
173
+ name: str,
174
+ metric_type: MetricType = MetricType.DEFAULT,
175
+ granularity: MetricGranularity = MetricGranularity.RUN,
176
+ distribution_type: str | None = None,
177
+ summary: str | None = None,
178
+ replace: bool = False) -> None:
179
+ """Define a new metric for the run.
180
+
181
+ Args:
182
+ name (str): The name of the metric.
183
+ metric_type (MetricType): The type of the metric.
184
+ granularity (MetricGranularity): The granularity of the metric.
185
+ distribution_type (str | None): The type of distribution if metric_type is DISTRIBUTION.
186
+ summary (str | None): Specify aggregate metrics added to summary.
187
+ Supported aggregations include "min", "max", "mean", "last",
188
+ "first", and "none". "none" prevents a summary
189
+ from being generated.
190
+ replace (bool): Whether to replace the metric if it already exists.
191
+ """
192
+ if name not in self._metrics or replace:
193
+ if metric_type == MetricType.DISTRIBUTION:
194
+ if distribution_type is None:
195
+ raise ValueError("distribution_type must be specified for distribution metrics.")
196
+ self._metrics[name] = DistributionMetric(name=name,
197
+ distribution_type=distribution_type,
198
+ run_id=self._id,
199
+ episode_id=self._episode,
200
+ granularity=granularity)
201
+ elif summary is not None:
202
+ self._metrics[name] = Summary(name=name,
203
+ summary=summary,
204
+ run_id=self._id,
205
+ episode_id=self._episode,
206
+ granularity=granularity)
207
+ else:
208
+ self._metrics[name] = Metrics(name=name,
209
+ metric_type=metric_type,
210
+ run_id=self._id,
211
+ episode_id=self._episode,
212
+ granularity=granularity)
213
+ else:
214
+ raise ValueError(f"Metric {name} already exists.")
humalab/scenario.py ADDED
@@ -0,0 +1,225 @@
1
+ from typing import Any
2
+ import numpy as np
3
+ from omegaconf import OmegaConf, ListConfig, AnyNode, DictConfig
4
+ import yaml
5
+ from humalab_sdk.dists.bernoulli import Bernoulli
6
+ from humalab_sdk.dists.categorical import Categorical
7
+ from humalab_sdk.dists.uniform import Uniform
8
+ from humalab_sdk.dists.discrete import Discrete
9
+ from humalab_sdk.dists.log_uniform import LogUniform
10
+ from humalab_sdk.dists.gaussian import Gaussian
11
+ from humalab_sdk.dists.truncated_gaussian import TruncatedGaussian
12
+ from functools import partial
13
+ from humalab_sdk.constants import EpisodeStatus
14
+ from humalab_sdk.metrics.dist_metric import DistributionMetric
15
+ from humalab_sdk.metrics.metric import MetricGranularity
16
+ import copy
17
+ import uuid
18
+
19
+ DISTRIBUTION_MAP = {
20
+ "uniform": Uniform,
21
+ "bernoulli": Bernoulli,
22
+ "categorical": Categorical,
23
+ "discrete": Discrete,
24
+ "log_uniform": LogUniform,
25
+ "gaussian": Gaussian,
26
+ "truncated_gaussian": TruncatedGaussian,
27
+ }
28
+
29
+ DISTRIBUTION_PARAM_NUM_MAP = {
30
+ "uniform": 3,
31
+ "bernoulli": 2,
32
+ "categorical": 3,
33
+ "discrete": 4,
34
+ "log_uniform": 3,
35
+ "gaussian": 3,
36
+ "truncated_gaussian": 5,
37
+ }
38
+
39
+ class Scenario:
40
+ dist_cache = {}
41
+ def __init__(self) -> None:
42
+ self._generator = np.random.default_rng()
43
+ self._scenario_template = OmegaConf.create()
44
+ self._cur_scenario = OmegaConf.create()
45
+ self._scenario_id = None
46
+
47
+ def init(self,
48
+ run_id: str,
49
+ episode_id: str,
50
+ scenario: str | list | dict | None = None,
51
+ seed: int | None=None,
52
+ scenario_id: str | None=None,
53
+ num_env: int | None = None) -> None:
54
+ """
55
+ Initialize the scenario with the given parameters.
56
+
57
+ Args:
58
+ run_id: The ID of the current run.
59
+ episode_id: The ID of the current episode.
60
+ scenario: The scenario configuration (YAML string, list, or dict).
61
+ seed: Optional seed for random number generation.
62
+ scenario_id: Optional scenario ID. If None, a new UUID is generated.
63
+ num_env: Optional number of parallel environments.
64
+ """
65
+ self._run_id = run_id
66
+ self._episode_id = episode_id
67
+ self._metrics = {}
68
+
69
+ self._num_env = num_env
70
+ self._scenario_id = scenario_id or str(uuid.uuid4())
71
+ self._generator = np.random.default_rng(seed)
72
+ self._configure()
73
+ scenario = scenario or {}
74
+ self._scenario_template = OmegaConf.create(scenario)
75
+ self.reset(episode_id=episode_id)
76
+
77
+ def _get_final_size(self, size: int | tuple[int, ...] | None) -> int | tuple[int, ...] | None:
78
+ n = self._num_env
79
+ if size is None:
80
+ return n
81
+ if n is None:
82
+ return size
83
+ if isinstance(size, int):
84
+ return (n, size)
85
+ return (n, *size)
86
+
87
+ def _get_node_path(self, root: dict | list, node: str) -> str:
88
+ if isinstance(root, list):
89
+ root = {str(i): v for i, v in enumerate(root)}
90
+
91
+ for key, value in root.items():
92
+ if value == node:
93
+ return str(key)
94
+ if isinstance(value, dict):
95
+ sub_path = self._get_node_path(value, node)
96
+ if sub_path:
97
+ return f"{key}.{sub_path}"
98
+ elif isinstance(value, list):
99
+ for idx, item in enumerate(value):
100
+ if item == node:
101
+ return f"{key}[{idx}]"
102
+ if isinstance(item, (dict, list)):
103
+ sub_path = self._get_node_path(item, node)
104
+ if sub_path:
105
+ return f"{key}[{idx}].{sub_path}"
106
+ return ""
107
+
108
+ @staticmethod
109
+ def _convert_to_python(obj) -> Any:
110
+ if not isinstance(obj, (np.ndarray, np.generic)):
111
+ return obj
112
+
113
+ # NumPy scalar (np.generic) or 0-D ndarray
114
+ if isinstance(obj, np.generic) or (isinstance(obj, np.ndarray) and obj.ndim == 0):
115
+ return obj.item()
116
+
117
+ # Regular ndarray (1-D or higher)
118
+ if isinstance(obj, np.ndarray):
119
+ return obj.tolist()
120
+
121
+ return obj
122
+
123
+ def _configure(self) -> None:
124
+ self._clear_resolvers()
125
+ def distribution_resolver(dist_name: str, *args, _node_, _root_, _parent_, **kwargs):
126
+ if len(args) > DISTRIBUTION_PARAM_NUM_MAP[dist_name]:
127
+ args = args[:DISTRIBUTION_PARAM_NUM_MAP[dist_name]]
128
+ print(f"Warning: Too many parameters for {dist_name}, expected {DISTRIBUTION_PARAM_NUM_MAP[dist_name]}, got {len(args)}. Extra parameters will be ignored.")
129
+
130
+ # print("_node_: ", _node_, type(_node_))
131
+ # print("_root_: ", _root_, type(_root_))
132
+ # print("_parent_: ", _parent_, type(_parent_))
133
+ # print("Args: ", args, len(args))
134
+ # print("Kwargs: ", kwargs)
135
+
136
+ root_yaml = yaml.safe_load(OmegaConf.to_yaml(_root_))
137
+ key_path = self._get_node_path(root_yaml, str(_node_))
138
+ # print("Key path: ", key_path)
139
+
140
+ if key_path not in self._metrics:
141
+ self._metrics[key_path] = DistributionMetric(name=key_path,
142
+ distribution_type=dist_name,
143
+ run_id=self._run_id,
144
+ episode_id=self._episode_id,
145
+ granularity=MetricGranularity.EPISODE)
146
+
147
+ shape = None
148
+
149
+ if len(args) == DISTRIBUTION_PARAM_NUM_MAP[dist_name]:
150
+ shape = args[DISTRIBUTION_PARAM_NUM_MAP[dist_name] - 1]
151
+ args = args[:-1]
152
+ shape = self._get_final_size(shape)
153
+
154
+ key = str(_node_)
155
+ if key not in Scenario.dist_cache:
156
+ Scenario.dist_cache[key] = DISTRIBUTION_MAP[dist_name].create(self._generator, *args, size=shape, **kwargs)
157
+ ret_val = Scenario.dist_cache[key].sample()
158
+ ret_val = Scenario._convert_to_python(ret_val)
159
+
160
+ if isinstance(ret_val, list):
161
+ ret_val = ListConfig(ret_val)
162
+ self._metrics[key_path].log(ret_val)
163
+ return ret_val
164
+
165
+ for dist_name in DISTRIBUTION_MAP.keys():
166
+ OmegaConf.register_new_resolver(dist_name, partial(distribution_resolver, dist_name))
167
+
168
+ def _clear_resolvers(self) -> None:
169
+ self.dist_cache.clear()
170
+ OmegaConf.clear_resolvers()
171
+
172
+ def __getattr__(self, name: Any) -> Any:
173
+ if name in self._cur_scenario:
174
+ return self._cur_scenario[name]
175
+ raise AttributeError(f"'Scenario' object has no attribute '{name}'")
176
+
177
+ def __getitem__(self, key: Any) -> Any:
178
+ if key in self._cur_scenario:
179
+ return self._cur_scenario[key]
180
+ raise KeyError(f"'Scenario' object has no key '{key}'")
181
+
182
+ def reset(self,
183
+ episode_id: str | None = None) -> None:
184
+ """Reset the scenario for a new episode.
185
+
186
+ Args:
187
+ episode_id: Optional new episode ID. If None, keeps the current episode ID.
188
+ """
189
+ for metric in self._metrics.values():
190
+ metric.reset(episode_id=episode_id)
191
+ self._cur_scenario = copy.deepcopy(self._scenario_template)
192
+ OmegaConf.resolve(self._cur_scenario)
193
+
194
+ def finish(self) -> None:
195
+ """Finish the scenario and submit final metrics.
196
+ """
197
+ for metric in self._metrics.values():
198
+ metric.finish()
199
+
200
+ @property
201
+ def template(self) -> Any:
202
+ """The template scenario configuration.
203
+
204
+ Returns:
205
+ Any: The template scenario as an OmegaConf object.
206
+ """
207
+ return self._scenario_template
208
+
209
+ @property
210
+ def cur_scenario(self) -> Any:
211
+ """The current scenario configuration.
212
+
213
+ Returns:
214
+ Any: The current scenario as an OmegaConf object.
215
+ """
216
+ return self._cur_scenario
217
+
218
+ @property
219
+ def yaml(self) -> str:
220
+ """The current scenario configuration as a YAML string.
221
+
222
+ Returns:
223
+ str: The current scenario as a YAML string.
224
+ """
225
+ return OmegaConf.to_yaml(self._cur_scenario)