gameboy_worlds.emulation.tracker
1from gameboy_worlds.emulation.parser import StateParser 2from gameboy_worlds.utils import ( 3 nested_dict_to_str, 4 verify_parameters, 5 log_info, 6 log_error, 7 log_warn, 8 log_dict, 9 show_frames, 10) 11 12 13import numpy as np 14from typing import Optional, Type, Dict, Any, Tuple, List, Set 15 16from abc import ABC, abstractmethod 17 18EPSILON = 0.001 19""" Default epsilon for frame change detection. """ 20 21 22class MetricGroup(ABC): 23 """ 24 Abstract Base class for organizing related metrics. 25 26 ### Documentation Guidlines: 27 Every subchild should document the following in their class docstrings: 28 - Reports (List of keys that are present in the return dict of `report`) 29 - Final Reports (List of keys that are present in the return dict of `report_final`) 30 31 """ 32 33 NAME = "base" 34 """ Name of the MetricGroup. """ 35 36 REQUIRED_PARSER = StateParser 37 """ The StateParser which implements the minimum required functionality for this MetricGroup to work. """ 38 39 def __init__(self, state_parser: StateParser, parameters: dict): 40 verify_parameters(parameters) 41 if not issubclass(type(state_parser), self.REQUIRED_PARSER): 42 log_error( 43 f"StateParser of type {type(state_parser)} is not compatible with MetricGroup requiring {self.REQUIRED_PARSER}." 44 ) 45 self.state_parser = state_parser 46 """ An instance of the StateParser to parse game state variables. """ 47 self._parameters = parameters 48 self.start() 49 self.final_metrics: Dict[str, Any] = None 50 """ Dictionary to store final metrics after environment close. """ 51 52 def start(self): 53 """ 54 Called once when environment starts. 55 All subclasses should call super() AFTER initializing their own variables. 56 Only variables that will persist across episodes should be initialized here. 57 """ 58 self.reset(first=True) 59 60 @abstractmethod 61 def reset(self, first: bool = False): 62 """Called when environment resets. 63 64 Args: 65 first (bool): Whether this is the first reset of the environment. If True, might need to aggregate metrics into running final totals. 66 """ 67 raise NotImplementedError 68 69 @abstractmethod 70 def close(self): 71 """ 72 Called when environment closes. Good for computing summary stats. 73 74 Step will not be called after this. 75 """ 76 raise NotImplementedError 77 78 @abstractmethod 79 def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]): 80 """ 81 Called each environment step to update metrics. 82 Args: 83 current_frame (np.ndarray): The current frame rendered by the emulator. 84 recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled. 85 """ 86 raise NotImplementedError 87 88 @abstractmethod 89 def report(self) -> Dict[str, Any]: 90 """ 91 Return metrics as dictionary for instantaneous variable tracking. 92 93 :return: Dictionary of metrics 94 :rtype: Dict[str, Any] 95 """ 96 raise NotImplementedError 97 98 @abstractmethod 99 def report_final(self) -> dict: 100 """ 101 Return metrics as dictionary for logging. Called at end of environment (before close). 102 Will never be called before `self.close`. 103 104 :return: Dictionary of metrics 105 :rtype: Dict[str, Any] 106 """ 107 raise NotImplementedError 108 109 def log_info(self, message: str): 110 """ 111 Logs with MetricGroup's name. Primarily for debugging. 112 """ 113 log_info(f"[Metric({self.NAME})]: {message}", self._parameters) 114 115 def log_warn(self, message: str): 116 """ 117 Logs with MetricGroup's name. Primarily for debugging. 118 """ 119 log_warn(f"[Metric({self.NAME})]: {message}", self._parameters) 120 121 def log_report(self): 122 """ 123 Logs the current metrics report with MetricGroup's name. Primarily for debugging. 124 """ 125 log_info(f"Metric({self.NAME}):\n") 126 log_dict(self.report(), parameters=self._parameters) 127 128 129class CoreMetrics(MetricGroup): 130 """ 131 Tracks basic metrics that are guaranteed to be available in state tracker reports for any and all games: 132 133 Reports: 134 - `steps`: Number of steps taken in the episode. 135 - `frame_changed`: Whether the frame has changed since the last step. 136 - `current_frame`: The current frame. 137 - `passed_frames`: All frames that have passed since the last step. 138 139 Final Reports: 140 - `total_episodes`: Total number of episodes completed. 141 - `average_steps_per_episode`: Average number of steps taken per episode. 142 - `max_steps`: Maximum number of steps taken in any episode. 143 - `min_steps`: Minimum number of steps taken in any episode. 144 - `std_steps`: Standard deviation of steps taken across episodes. 145 146 """ 147 148 NAME = "core" 149 150 def start(self): 151 self.steps_per_episode = [] 152 """ List of steps taken in each episode. """ 153 super().start() 154 155 def reset(self, first=False): 156 if not first: 157 self.steps_per_episode.append(self.steps) 158 else: 159 self.steps = 0 160 """ Number of steps taken in the episode. """ 161 self.previous_frame = None 162 """ Previous frame for detecting changes. """ 163 self.current_frame = None 164 """ Current frame. """ 165 self.frame_changed = True 166 """ Whether the frame has changed at all since last step. """ 167 self.passed_frames = None 168 """ Stack of frames since the last step """ 169 170 def close(self): 171 if len(self.steps_per_episode) > 0: 172 total_episodes = len(self.steps_per_episode) 173 average_steps = np.mean(self.steps_per_episode) 174 max_steps = np.max(self.steps_per_episode) 175 min_steps = np.min(self.steps_per_episode) 176 std_steps = np.std(self.steps_per_episode) 177 else: 178 total_episodes = 0 179 average_steps = 0.0 180 max_steps = 0 181 min_steps = 0 182 std_steps = 0.0 183 self.final_metrics = { 184 "total_episodes": int(total_episodes), 185 "average_steps_per_episode": float(average_steps), 186 "max_steps": int(max_steps), 187 "min_steps": int(min_steps), 188 "std_steps": float(std_steps), 189 } 190 191 def step(self, current_frame, recent_frames): 192 self.steps += 1 193 self.current_frame = current_frame 194 self.passed_frames = recent_frames 195 if self.previous_frame is None: 196 self.previous_frame = current_frame 197 self.frame_changed = True 198 else: 199 frame_changed = False 200 comparison_frame = self.previous_frame 201 if recent_frames is None: 202 recent_frames = np.array([current_frame]) 203 for frame in recent_frames: 204 if np.abs(frame - comparison_frame).mean() > EPSILON: 205 frame_changed = True 206 else: 207 frame_changed = False 208 comparison_frame = frame 209 if frame_changed: 210 break 211 self.frame_changed = frame_changed 212 self.previous_frame = current_frame 213 214 def report(self): 215 """ 216 Provides the following metrics: 217 - `steps`: Number of steps taken in the episode. 218 - `frame_changed`: Whether the frame has changed since the last step. 219 - `current_frame`: The current frame. 220 - `passed_frames`: All frames that have passed since the last step. 221 """ 222 return { 223 "steps": self.steps, 224 "frame_changed": self.frame_changed, 225 "current_frame": self.current_frame, 226 "passed_frames": self.passed_frames, 227 } 228 229 def report_final(self): 230 """ 231 Provides the following metrics: 232 - `total_episodes`: Total number of episodes completed. 233 - `average_steps_per_episode`: Average number of steps taken per episode. 234 - `max_steps`: Maximum number of steps taken in any episode. 235 - `min_steps`: Minimum number of steps taken in any episode. 236 - `std_steps`: Standard deviation of steps taken across episodes. 237 """ 238 return self.final_metrics 239 240 241class OCRegionMetric(MetricGroup, ABC): 242 """ 243 Watch particular screen regions and capture subscreens for OCR when possible. Does not actually perform OCR itself, but makes it easy to capture the relevant regions. 244 Children implementing this must define self.kinds in `start()` and then call on `super().start()`. 245 246 Reports: 247 - `ocr_regions`: A dictionary mapping kinds to captured regions that had OCR-eligible text detected in them. The keys are kinds of OCR regions, and the values are the stacks of captured screen regions as numpy arrays of shape (num_captures, height, width, channels). 248 - `step`: The current step number. Useful for differentiating when multiple OCR texts were found in the same episode. You can typically safely ignore this. 249 250 Final Reports: 251 - `ocr_regions`: A list of tuples for all steps where OCR was detected. Is in form: `List[Tuple[int, Dict[str, np.ndarray]]]` where the int is the step number and the Dict maps kinds to a stack of the captured screen region. 252 253 """ 254 255 NAME = "ocr" 256 257 def start(self): 258 """ 259 Assumes the child has initialized a dict called self.kinds which tracks the various kinds of OCR that could be done. 260 self.kinds should be in the form: {kind: region_name} where region_name is the name of the region to OCR for that kind. 261 Will track ocr captured region results in form of list of dictionaries where these kinds are keys. 262 """ 263 super().start() 264 if self.NAME != "ocr": 265 log_error( 266 "OCRMetric subclasses must have NAME equal to 'ocr' for the environment get_info() aggregation step to work.", 267 self._parameters, 268 ) 269 if not hasattr(self, "kinds"): 270 log_error("OCRMetrics must declare self.kinds dictionary", self._parameters) 271 elif not isinstance(self.kinds, dict): 272 log_error("self.kinds must be a dictionary", self._parameters) 273 self.kinds: dict 274 for item in self.kinds: 275 if not isinstance(item, str): 276 log_error("self.kinds keys must be strings", self._parameters) 277 region_info = self.kinds[item] 278 if not isinstance(region_info, str): 279 log_error( 280 "self.kinds values must be region names (strings)", self._parameters 281 ) 282 if region_info not in self.state_parser.named_screen_regions: 283 log_error( 284 f"OCR region name {region_info} not found in state parser named regions. Available options: {self.state_parser.named_screen_regions}", 285 self._parameters, 286 ) 287 288 @staticmethod 289 def can_read_kind(self, frame: np.ndarray, kind: str) -> bool: 290 """ 291 Checks if the frame has text for the given kind. 292 293 Args: 294 frame (np.ndarray): The frame to check. 295 kind (str): The kind of text to check for. 296 """ 297 raise NotImplementedError 298 299 def reset(self, first=False): 300 """ 301 ocr_regions will track a list of the form List[Tuple[int, Dict[str, np.ndarray]]] 302 which is a list of (step_number, {kind: ocr_region}) dictionaries. 303 """ 304 self.ocr_regions = [] 305 self.steps = 0 306 self.prev_has_ocr = False 307 308 def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]): 309 all_frames = None 310 if recent_frames is not None: 311 all_frames = recent_frames # Current frame is included in recent frames 312 else: 313 all_frames = np.array([current_frame]) 314 ocr_dict = {} 315 # Aggregate results for all frames and separate per kind. 316 for kind in self.kinds.keys(): 317 captured_frames = [] 318 for frame in all_frames: 319 if self.can_read_kind(frame, kind): 320 captured_frames.append( 321 self.state_parser.capture_named_region( 322 current_frame=frame, name=self.kinds[kind] 323 ) 324 ) 325 true_captured_frames = [] # remove duplicates for efficiency 326 # add the later frame in case of overlap detection. This prefers more text for cases where text comes as a stream. 327 if len(captured_frames) > 0: 328 for i in range(len(captured_frames) - 1): 329 curr_frame = captured_frames[i] 330 next_frame = captured_frames[i + 1] 331 if np.abs(curr_frame - next_frame).mean() > EPSILON: 332 true_captured_frames.append(curr_frame) 333 true_captured_frames.append( 334 captured_frames[-1] 335 ) # always add the last frame. 336 ocr_dict[kind] = np.array(true_captured_frames) 337 if len(ocr_dict) > 0: 338 self.ocr_regions.append((self.steps, ocr_dict)) 339 self.prev_has_ocr = True 340 else: 341 self.prev_has_ocr = False 342 self.steps += 1 343 344 def report(self): 345 """ 346 Reports just the previous step's OCR regions if any were found. 347 Returns: 348 dict: A dictionary containing the captured regions for focused OCR. 349 """ 350 if self.prev_has_ocr: 351 return { 352 "ocr_regions": self.ocr_regions[-1][1], 353 "step": self.ocr_regions[-1][0], 354 } 355 else: 356 return {} 357 358 def report_final(self): 359 """ 360 Reports all the OCR regions extracted in the episode. 361 """ 362 return {"ocr_regions": self.ocr_regions} 363 364 def close(self): 365 pass 366 367 368class SubGoal(ABC): 369 """ 370 Abstract class representing a subgoal for tracking progress towards a test goal. These are intermediate states that must be achieved on the way to the final test goal. 371 By convention, the task goal state itself is *not* considered a subgoal, but rather the final goal that the subgoals lead towards. 372 """ 373 374 NAME = "placeholder" 375 """ Name of the subgoal. """ 376 377 def __init__(self): 378 if self.NAME == "placeholder": 379 log_error( 380 "Subclasses of SubGoal must set a unique NAME class variable.", 381 ) 382 self.completed = False 383 384 def check_completed(self, frames: np.ndarray, parser: StateParser) -> bool: 385 """ 386 Checks whether the subgoal has been completed based on the given frames and state parser. 387 388 Args: 389 frames (np.ndarray): The stack of frames to check for subgoal completion. 390 parser (StateParser): The state parser to use for checking subgoal completion. 391 Returns: 392 bool: True if the subgoal is completed, False otherwise. 393 """ 394 for frame in frames: 395 if self._check_completed(frame, parser): 396 return True 397 return False 398 399 @abstractmethod 400 def _check_completed(self, frame: np.ndarray, parser: StateParser) -> bool: 401 """ 402 Checks whether the subgoal has been completed based on a single frame and the state parser. 403 404 Args: 405 frames (np.ndarray): A single frame to check for subgoal completion. 406 parser (StateParser): The state parser to use for checking subgoal completion. 407 Returns: 408 bool: True if the subgoal is completed, False otherwise. 409 """ 410 pass 411 412 413class DummySubGoal(SubGoal): 414 """ 415 A dummy subgoal that is never completed. Useful for testing. 416 """ 417 418 NAME = "dummy_subgoal" 419 420 def _check_completed(self, frame: np.ndarray, parser: StateParser) -> bool: 421 return False 422 423 424class RegionMatchSubGoal(SubGoal, ABC): 425 """ 426 A subgoal that is completed if a specific region matches a target. Can be used to track subgoals that require specific dialogue boxes to appear, etc. 427 """ 428 429 NAME = "placeholder" 430 _NAMED_REGION: str = None 431 _TARGET_NAME: str = None 432 433 def __init__(self): 434 super().__init__() 435 if self._NAMED_REGION is None or self._TARGET_NAME is None: 436 log_error( 437 "Subclasses of RegionMatchSubGoal must set _NAMED_REGION and _TARGET_NAME class variables.", 438 ) 439 440 def _check_completed(self, frame: np.ndarray, parser: StateParser) -> bool: 441 matches = parser.named_region_matches_multi_target( 442 frame, self._NAMED_REGION, self._TARGET_NAME 443 ) 444 return matches 445 446 447class SingleRegionMatchSubGoal(SubGoal, ABC): 448 """ 449 A subgoal that is completed if a specific single region matches its target. 450 """ 451 452 NAME = "placeholder" 453 _NAMED_REGION: str = None 454 455 def __init__(self): 456 super().__init__() 457 if self._NAMED_REGION is None: 458 log_error( 459 "Subclasses of SingleRegionMatchSubGoal must set _NAMED_REGION class variable.", 460 ) 461 462 def _check_completed(self, frame, parser): 463 matches = parser.named_region_matches_target(frame, self._NAMED_REGION) 464 return matches 465 466 467class AnyRegionMatchSubGoal(SubGoal, ABC): 468 """ 469 A subgoal that is completed if any of a list of specific regions matches their targets. 470 """ 471 472 NAME = "placeholder" 473 _NAMED_REGIONS: List[str] = None 474 _TARGET_NAMES: List[str] = None 475 476 def __init__(self): 477 super().__init__() 478 if ( 479 self._NAMED_REGIONS is None 480 or self._TARGET_NAMES is None 481 or len(self._NAMED_REGIONS) != len(self._TARGET_NAMES) 482 or len(self._NAMED_REGIONS) == 0 483 ): 484 log_error( 485 "Subclasses of AnyRegionMatchSubGoal must set _NAMED_REGIONS and _TARGET_NAMES class variables, and they must be of the same length non zero.", 486 ) 487 488 def _check_completed(self, frame: np.ndarray, parser: StateParser) -> bool: 489 for named_region, target_name in zip(self._NAMED_REGIONS, self._TARGET_NAMES): 490 matches = parser.named_region_matches_multi_target( 491 frame, named_region, target_name 492 ) 493 if matches: 494 return True 495 return False 496 497 498class AnySingleRegionMatchSubGoal(SubGoal, ABC): 499 """ 500 A subgoal that is completed if any of a list of specific regions matches their targets, where each region only has one target. 501 """ 502 503 NAME = "placeholder" 504 _NAMED_REGIONS: List[str] = None 505 506 def __init__(self): 507 super().__init__() 508 if self._NAMED_REGIONS is None or len(self._NAMED_REGIONS) == 0: 509 log_error( 510 "Subclasses of AnySingleRegionMatchSubGoal must set _NAMED_REGIONS class variable, and it must be non empty.", 511 ) 512 513 def _check_completed(self, frame: np.ndarray, parser: StateParser) -> bool: 514 for named_region in self._NAMED_REGIONS: 515 matches = parser.named_region_matches_target(frame, named_region) 516 if matches: 517 return True 518 return False 519 520 521class SubGoalMetric(MetricGroup, ABC): 522 """ 523 Tracks subgoal based progress towards a specific test goal. 524 Subgoals are always sequential, i.e. it is impossible to complete subgoal n+1 without completing subgoal n first. 525 526 Reports: 527 - `all`: A list of the names of all subgoals being tracked, regardless of completion status. 528 - `completed`: A list of the names of the subgoals that have been completed. 529 530 Final Reports: 531 - `reached_subgoals`: List of subgoals that were reached at any point during any episode. 532 """ 533 534 NAME = "subgoals" 535 SUBGOALS: List[SubGoal] = [] 536 """ List of SubGoal classes representing the subgoals to be tracked. These should be defined in child classes. """ 537 538 def start(self): 539 if self.NAME != "subgoals": 540 log_error( 541 f"SubGoalMetric NAME must be 'subgoals', got '{self.NAME}'.", 542 self._parameters, 543 ) 544 if len(self.SUBGOALS) == 0: 545 log_error( 546 "SubGoalMetric requires at least one subgoal to be defined in the SUBGOALS class variable.", 547 self._parameters, 548 ) 549 self._subgoals: List[SubGoal] = [] 550 """ List of SubGoal instances representing the subgoals being tracked. """ 551 for subgoal_class in self.SUBGOALS: 552 subgoal_instance: SubGoal = subgoal_class() 553 self._subgoals.append(subgoal_instance) 554 self._reached_subgoals: Set[str] = set() 555 """ Set of subgoals that were reached at any point during any episode. """ 556 super().start() 557 558 def close(self): 559 pass 560 561 def reset(self, first=False): 562 if not first: 563 for subgoal in self._subgoals: 564 if subgoal.completed: 565 self._reached_subgoals.add(subgoal.NAME) 566 for subgoal in self._subgoals: 567 subgoal.completed = False 568 569 def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]): 570 all_frames = None 571 if recent_frames is not None: 572 all_frames = recent_frames # Current frame is included in recent frames 573 else: 574 all_frames = np.array([current_frame]) 575 for subgoal in self._subgoals: 576 if not subgoal.completed: 577 completed = subgoal.check_completed(all_frames, self.state_parser) 578 subgoal.completed = completed 579 580 def report(self): 581 """ 582 Reports the names of the subgoals being tracked and which ones have been completed. 583 Returns: 584 dict: A dictionary containing the list of all subgoals and the list of completed subgoals. 585 """ 586 return { 587 "all": [subgoal.NAME for subgoal in self._subgoals], 588 "completed": [ 589 subgoal.NAME for subgoal in self._subgoals if subgoal.completed 590 ], 591 } 592 593 def report_final(self): 594 """ 595 Reports the names of the subgoals that were reached at any point during any episode. 596 Returns: 597 dict: A dictionary containing the list of reached subgoals. 598 """ 599 return {"reached_subgoals": list(self._reached_subgoals)} 600 601 602class DummySubGoalMetric(SubGoalMetric): 603 """ 604 A dummy SubGoalMetric that tracks a single DummySubGoal. Useful for testing. 605 """ 606 607 SUBGOALS = [DummySubGoal] 608 609 610def make_subgoal_metric_class(subgoals: List[Type[SubGoal]]) -> Type[SubGoalMetric]: 611 """ 612 Factory function to create a SubGoalMetric class with the given subgoals and name. 613 614 Args: 615 subgoals (List[Type[SubGoal]]): The list of SubGoal classes to track. 616 name (str): The name of the SubGoalMetric class. 617 618 Returns: 619 Type[SubGoalMetric]: A new SubGoalMetric class with the specified subgoals and name. 620 """ 621 if len(subgoals) == 0: 622 log_error("Must provide at least one subgoal to create a SubGoalMetric class.") 623 624 class CustomSubGoalMetric(SubGoalMetric): 625 SUBGOALS = subgoals 626 627 return CustomSubGoalMetric 628 629 630class TerminationTruncationMetric(MetricGroup, ABC): 631 """ 632 Tracks whether the environment was terminated or truncated. 633 634 Reports: 635 - `terminated`: Whether the environment was terminated. 636 - `truncated`: Whether the environment was truncated. 637 638 Final Reports: 639 - `episode_end_reason`: List of reasons for episode endings: "terminated", "truncated", or None (None will occur only if there is a bug that leads to a premature reset). 640 """ 641 642 NAME = "termination_truncation" 643 644 def start(self): 645 super().start() 646 if self.NAME != "termination_truncation": 647 log_error( 648 f"TerminationTruncationMetric NAME must be 'termination_truncation', got '{self.NAME}'.", 649 self._parameters, 650 ) 651 self.episode_end_reason = [] 652 """ List of reasons for episode: termination or truncation or None (None will occur only if there is a bug that leads to a premature reset). """ 653 self.terminated = False 654 """ Whether the environment was terminated. """ 655 self.truncated = False 656 """ Whether the environment was truncated. """ 657 658 def reset(self, first=False): 659 if not first: 660 if self.terminated: 661 self.episode_end_reason.append("terminated") 662 elif self.truncated: 663 self.episode_end_reason.append("truncated") 664 else: 665 self.episode_end_reason.append(None) 666 self.terminated = False 667 self.truncated = False 668 669 def close(self): 670 pass 671 672 @abstractmethod 673 def determine_truncated( 674 self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray] 675 ) -> bool: 676 """ 677 Determines whether the environment was truncated. 678 679 :param current_frame: The current frame rendered by the emulator. 680 :type current_frame: np.ndarray 681 :param recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled. 682 :type recent_frames: Optional[np.ndarray] 683 :return: True if the environment was truncated, False otherwise. 684 :rtype: bool 685 """ 686 pass 687 688 @abstractmethod 689 def determine_terminated( 690 self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray] 691 ) -> bool: 692 """ 693 Determines whether the environment was terminated. 694 695 :param current_frame: The current frame rendered by the emulator. 696 :type current_frame: np.ndarray 697 :param recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled. 698 :type recent_frames: Optional[np.ndarray] 699 :return: True if the environment was terminated, False otherwise. 700 :rtype: bool 701 """ 702 pass 703 704 def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]): 705 """ 706 Determines whether the environment was terminated or truncated. 707 """ 708 if self.terminated or self.truncated: 709 return # This should ideally not happen, because the environment should reset after termination or truncation. 710 self.truncated = self.determine_truncated(current_frame, recent_frames) 711 self.terminated = self.determine_terminated(current_frame, recent_frames) 712 713 def report(self): 714 """ 715 Reports whether the environment was terminated or truncated. 716 Returns: 717 dict: A dictionary containing the termination and truncation status. 718 """ 719 return { 720 "terminated": self.terminated, 721 "truncated": self.truncated, 722 } 723 724 def report_final(self): 725 """ 726 Reports the reasons for episode endings. 727 Returns: 728 dict: A dictionary containing the list of episode end reasons. 729 """ 730 return {"episode_end_reason": self.episode_end_reason} 731 732 733class TerminationMetric(TerminationTruncationMetric, ABC): 734 def determine_truncated( 735 self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray] 736 ) -> bool: 737 return False 738 739 740class StateTracker: 741 """ 742 Tracks and provides API access to the game state / metrics over time and across episodes. 743 The most hassle-free way to read from the StateTracker is to use the `report()` and `report_final()` methods to get nested dictionaries of all metrics tracked. 744 745 **Example Usage:** 746 747 ```python 748 import numpy as np 749 from gameboy_worlds import get_pokemon_emulator 750 emulator = get_pokemon_emulator(variant="pokemon_red") 751 752 # We can access the StateTracker via the emulator 753 state_tracker = emulator.state_tracker 754 755 # Run a random action on the emulator 756 emulator.reset() 757 allowed_actions = list(LowLevelActions) 758 action = np.random.choice(allowed_actions) 759 _, _ = emulator.step(action) # also updates the StateTracker internally 760 # We can access the current episode metrics via the StateTracker 761 episode_metrics = state_tracker.report() # access all of them as a nested dict 762 specific_metric = state_tracker.get_episode_metric(("core", "steps")) # access specific metrics 763 764 # If we reset the emulator, the StateTracker will reset its inter-episode metrics as well 765 emulator.reset() 766 action = np.random.choice(allowed_actions) 767 _, _ = emulator.step(action) 768 emulator.close() # StateTracker will finalize its metrics internally 769 final_metrics = state_tracker.report_final() # access all of them as a nested dict 770 specific_final_metric = state_tracker.get_final_metric(("core", "average_steps_per_episode")) # access specific final metrics 771 ``` 772 """ 773 774 TERMINATION_TRUNCATION_METRIC: Type[TerminationTruncationMetric] = None 775 """ The TerminationTruncationMetric class to use for tracking termination and truncation. If None, no such metric will be tracked. """ 776 777 SUBGOAL_METRIC: Type[SubGoalMetric] = None 778 """ The SubGoalMetric class to use for tracking subgoal progress. If None, no such metric will be tracked. """ 779 780 def __init__( 781 self, 782 state_parser: StateParser, 783 parameters: dict, 784 ): 785 """ 786 Initializes the StateTracker. 787 Args: 788 state_parser (StateParser): An instance of the StateParser to parse game state variables. 789 parameters (dict): A dictionary of parameters for configuration. 790 """ 791 verify_parameters(parameters) 792 self.state_parser = state_parser 793 """ An instance of the StateParser to parse game state variables. """ 794 self._parameters = parameters 795 self.start() 796 self.validate() 797 if self.metric_classes[0] != CoreMetrics: 798 log_error( 799 "First metric class must be CoreMetrics. Make sure to call `super().start()` first in child class overrides of `start()`.", 800 parameters, 801 ) 802 self.metrics = {} 803 """ Dictionary to store MetricGroup instances. """ 804 for metric_group_class in self.metric_classes: 805 metric_group_instance: MetricGroup = metric_group_class( 806 state_parser, parameters 807 ) 808 self.metrics[metric_group_instance.NAME] = metric_group_instance 809 self.episode_metrics: Dict[str, Dict[str, Any]] = {} 810 """ Dictionary to store metrics running during episode. """ 811 self.final_metrics: Dict[str, Dict[str, Any]] = {} 812 813 def start(self): 814 """ 815 Sets up the metrics for the tracker by creating the list `self.metric_classes` 816 817 Child classes must FIRST call super().start() and THEN set up their own metric classes. 818 """ 819 self.metric_classes: List[Type[MetricGroup]] = [CoreMetrics] 820 if self.TERMINATION_TRUNCATION_METRIC is not None: 821 if not issubclass( 822 self.TERMINATION_TRUNCATION_METRIC, TerminationTruncationMetric 823 ): 824 log_error( 825 "TERMINATION_TRUNCATION_METRIC must be a subclass of TerminationTruncationMetric.", 826 self._parameters, 827 ) 828 self.metric_classes.append(self.TERMINATION_TRUNCATION_METRIC) 829 if self.SUBGOAL_METRIC is not None: 830 if not issubclass(self.SUBGOAL_METRIC, SubGoalMetric): 831 log_error( 832 "SUBGOAL_METRIC must be a subclass of SubGoalMetric.", 833 self._parameters, 834 ) 835 self.metric_classes.append(self.SUBGOAL_METRIC) 836 837 def validate(self): 838 """ 839 Is meant to be called once after initialization to ensure that the tracker is valid. 840 """ 841 pass 842 843 def reset(self): 844 """ 845 Is called once per environment reset to reset any tracked metrics. 846 """ 847 for metric_group in self.metrics.values(): 848 metric_group.reset() 849 self.step() 850 851 def step(self, recent_frames: Optional[np.ndarray] = None): 852 """ 853 Is called once per environment step to update any tracked metrics. 854 855 Args: 856 recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled. 857 epsilon (float, optional): The threshold for considering a frame change. 858 """ 859 current_frame = None 860 if recent_frames is None: 861 current_frame = self.state_parser.get_current_frame() 862 else: 863 current_frame = recent_frames[-1] 864 self.episode_metrics = {} 865 for metric_group in self.metrics.values(): 866 metric_group.step(current_frame, recent_frames) 867 self.episode_metrics[metric_group.NAME] = metric_group.report() 868 869 def close(self): 870 """ 871 Is called once when the environment is closed to finalize any tracked metrics. 872 """ 873 for metric_group in self.metrics.values(): 874 metric_group.close() 875 self.final_metrics = { 876 name: mg.report_final() for name, mg in self.metrics.items() 877 } 878 879 def report(self) -> Dict[str, Dict[str, Any]]: 880 """ 881 Returns the current episode metrics. 882 883 :return: A nested dictionary containing the current episode metrics. 884 :rtype: Dict[str, Dict[str, Any]] 885 """ 886 return self.episode_metrics 887 888 def report_final(self) -> Dict[str, Dict[str, Any]]: 889 """ 890 Returns the final metrics after environment close. 891 892 Returns: 893 Dict[str, Dict[str, Any]]: A nested dictionary containing the final metrics. 894 """ 895 return self.final_metrics 896 897 def _get_specific_metric(self, metrics_dict, key: Tuple[str, str]): 898 if metrics_dict is None: 899 log_error("No metrics available. Have you called step() or close()?") 900 metric_group_name, metric_name = key 901 if metric_group_name not in metrics_dict: 902 log_error( 903 f"Metric group {metric_group_name} not found in metrics. Available groups: {list(metrics_dict.keys())}" 904 ) 905 if metric_name not in metrics_dict[metric_group_name]: 906 log_error( 907 f"Metric {metric_name} not found in metric group {metric_group_name}. Available metrics: {list(metrics_dict[metric_group_name].keys())}" 908 ) 909 return metrics_dict[metric_group_name][metric_name] 910 911 def get_episode_metric(self, key: Tuple[str, str]): 912 """ 913 Returns the metrics for a specific episode and metric group. 914 915 Does not give final metrics at any point. 916 917 :param key: A tuple of the form (metric_group_name, metric_name). 918 :type key: Tuple[str, str] 919 :return: The requested metric value 920 :rtype: Any 921 """ 922 return self._get_specific_metric(self.episode_metrics, key) 923 924 def get_final_metric(self, key: Tuple[str, str]): 925 """ 926 Returns the final metrics for a specific metric group. 927 928 :param key: A tuple of the form (metric_group_name, metric_name). 929 :type key: Tuple[str, str] 930 :return: The requested final metric value 931 :rtype: Any 932 """ 933 return self._get_specific_metric(self.final_metrics, key) 934 935 def __repr__(self) -> str: 936 metric_names = [mg.NAME for mg in self.metrics.values()] 937 return f"<StateTracker, metrics=({', '.join(metric_names)})>" 938 939 940class TestTrackerMixin: 941 """ 942 Mixin class for testing trackers. 943 Ensures that exactly one of the tracked metrics is a TerminationTruncationMetric. 944 """ 945 946 def validate(self): 947 if not hasattr(self, "_parameters"): 948 log_error("Parameters have not been set yet.") 949 if self.TERMINATION_TRUNCATION_METRIC is None: 950 log_error( 951 "TestTrackerMixin requires a TerminationTruncationMetric to be set as TERMINATION_TRUNCATION_METRIC.", 952 self._parameters, 953 ) 954 if self.SUBGOAL_METRIC is None: 955 log_error( 956 "TestTrackerMixin requires a SubGoalMetric to be set as SUBGOAL_METRIC.", 957 self._parameters, 958 ) 959 960 961class RegionMatchTruncationMetric(TerminationTruncationMetric, ABC): 962 """ 963 Truncates the episode if a specific region matches a target. 964 Can be used to truncate episodes when specific dialogue boxes appear, etc. 965 """ 966 967 _TRUNCATION_NAMED_REGION = None 968 _TRUNCATION_TARGET_NAME = None 969 970 def determine_truncated(self, current_frame, recent_frames): 971 if ( 972 self._TRUNCATION_NAMED_REGION is None 973 or self._TRUNCATION_TARGET_NAME is None 974 ): 975 log_error( 976 "Must set _TRUNCATION_NAMED_REGION and _TRUNCATION_TARGET_NAME.", 977 self._parameters, 978 ) 979 all_frames = [current_frame] 980 if recent_frames is not None: 981 all_frames = recent_frames 982 for frame in all_frames: 983 matches = self.state_parser.named_region_matches_multi_target( 984 frame, 985 self._TRUNCATION_NAMED_REGION, 986 self._TRUNCATION_TARGET_NAME, 987 ) 988 if matches: 989 return True 990 return False 991 992 993class RegionMatchTerminationMetric(TerminationTruncationMetric, ABC): 994 """ 995 Terminates the episode if a specific region matches a target. 996 Can be used to terminate episodes when specific dialogue boxes appear, etc. 997 """ 998 999 _TERMINATION_NAMED_REGION = None 1000 _TERMINATION_TARGET_NAME = None 1001 1002 def determine_terminated(self, current_frame, recent_frames): 1003 if ( 1004 self._TERMINATION_NAMED_REGION is None 1005 or self._TERMINATION_TARGET_NAME is None 1006 ): 1007 log_error( 1008 "Must set _TERMINATION_NAMED_REGION and _TERMINATION_TARGET_NAME.", 1009 self._parameters, 1010 ) 1011 all_frames = [current_frame] 1012 if recent_frames is not None: 1013 all_frames = recent_frames 1014 for frame in all_frames: 1015 matches = self.state_parser.named_region_matches_multi_target( 1016 frame, 1017 self._TERMINATION_NAMED_REGION, 1018 self._TERMINATION_TARGET_NAME, 1019 ) 1020 if matches: 1021 return True 1022 return False 1023 1024 1025class RegionMatchTerminationOnlyMetric(TerminationMetric, ABC): 1026 """ 1027 RegionMatchTerminationMetric with no truncation. 1028 No truncation. 1029 """ 1030 1031 _TERMINATION_NAMED_REGION = None 1032 _TERMINATION_TARGET_NAME = None 1033 1034 def determine_terminated(self, current_frame, recent_frames): 1035 if ( 1036 self._TERMINATION_NAMED_REGION is None 1037 or self._TERMINATION_TARGET_NAME is None 1038 ): 1039 log_error( 1040 "Must set _TERMINATION_NAMED_REGION and _TERMINATION_TARGET_NAME.", 1041 self._parameters, 1042 ) 1043 all_frames = [current_frame] 1044 if recent_frames is not None: 1045 all_frames = recent_frames 1046 for frame in all_frames: 1047 matches = self.state_parser.named_region_matches_multi_target( 1048 frame, 1049 self._TERMINATION_NAMED_REGION, 1050 self._TERMINATION_TARGET_NAME, 1051 ) 1052 if matches: 1053 return True 1054 return False 1055 1056class AnyRegionMatchTerminationMetric(TerminationMetric, ABC): 1057 """ 1058 Terminates the episode if any of a list of specific regions matches their targets. 1059 No truncation. 1060 """ 1061 1062 _NAMED_REGIONS: List[str] = None 1063 _TARGET_NAMES: List[str] = None 1064 1065 def __init__(self): 1066 super().__init__() 1067 if ( 1068 self._NAMED_REGIONS is None 1069 or self._TARGET_NAMES is None 1070 or len(self._NAMED_REGIONS) != len(self._TARGET_NAMES) 1071 or len(self._NAMED_REGIONS) == 0 1072 ): 1073 log_error( 1074 "Subclasses of AnyRegionMatchTerminationMetric must set _NAMED_REGIONS and _TARGET_NAMES class variables, and they must be of the same length non zero.", 1075 ) 1076 1077 def determine_terminated(self, current_frame, recent_frames): 1078 all_frames = [current_frame] 1079 if recent_frames is not None: 1080 all_frames = recent_frames 1081 for frame in all_frames: 1082 for named_region, target_name in zip(self._NAMED_REGIONS, self._TARGET_NAMES): 1083 if self.state_parser.named_region_matches_multi_target( 1084 frame, named_region, target_name 1085 ): 1086 return True 1087 return False 1088 1089 1090class RegionChangedTerminationMetric(TerminationTruncationMetric, ABC): 1091 """ 1092 Terminates the episode if a specific named region changes significantly 1093 from its appearance at the start of the episode (on reset). 1094 1095 Useful for detecting pickups, stat changes, or any event that alters a 1096 HUD region without having a fixed reference capture. 1097 1098 Subclass and set: 1099 _CHANGED_NAMED_REGION: name of the NamedScreenRegion to monitor 1100 _CHANGE_MAE_THRESHOLD: MAE threshold above which the region is considered changed (default 10) 1101 """ 1102 1103 _CHANGED_NAMED_REGION = None 1104 _CHANGE_MAE_THRESHOLD = 10 1105 1106 def reset(self, first=False): 1107 super().reset(first=first) 1108 self._region_baseline = None 1109 1110 def determine_truncated(self, current_frame, recent_frames): 1111 return False 1112 1113 def determine_terminated(self, current_frame, recent_frames): 1114 if self._CHANGED_NAMED_REGION is None: 1115 log_error("Must set _CHANGED_NAMED_REGION.", self._parameters) 1116 cropped = self.state_parser.capture_named_region( 1117 current_frame, self._CHANGED_NAMED_REGION 1118 ) 1119 if self._region_baseline is None: 1120 self._region_baseline = cropped.copy() 1121 return False 1122 mae = np.abs(cropped.astype(float) - self._region_baseline.astype(float)).mean() 1123 return mae > self._CHANGE_MAE_THRESHOLD
Default epsilon for frame change detection.
23class MetricGroup(ABC): 24 """ 25 Abstract Base class for organizing related metrics. 26 27 ### Documentation Guidlines: 28 Every subchild should document the following in their class docstrings: 29 - Reports (List of keys that are present in the return dict of `report`) 30 - Final Reports (List of keys that are present in the return dict of `report_final`) 31 32 """ 33 34 NAME = "base" 35 """ Name of the MetricGroup. """ 36 37 REQUIRED_PARSER = StateParser 38 """ The StateParser which implements the minimum required functionality for this MetricGroup to work. """ 39 40 def __init__(self, state_parser: StateParser, parameters: dict): 41 verify_parameters(parameters) 42 if not issubclass(type(state_parser), self.REQUIRED_PARSER): 43 log_error( 44 f"StateParser of type {type(state_parser)} is not compatible with MetricGroup requiring {self.REQUIRED_PARSER}." 45 ) 46 self.state_parser = state_parser 47 """ An instance of the StateParser to parse game state variables. """ 48 self._parameters = parameters 49 self.start() 50 self.final_metrics: Dict[str, Any] = None 51 """ Dictionary to store final metrics after environment close. """ 52 53 def start(self): 54 """ 55 Called once when environment starts. 56 All subclasses should call super() AFTER initializing their own variables. 57 Only variables that will persist across episodes should be initialized here. 58 """ 59 self.reset(first=True) 60 61 @abstractmethod 62 def reset(self, first: bool = False): 63 """Called when environment resets. 64 65 Args: 66 first (bool): Whether this is the first reset of the environment. If True, might need to aggregate metrics into running final totals. 67 """ 68 raise NotImplementedError 69 70 @abstractmethod 71 def close(self): 72 """ 73 Called when environment closes. Good for computing summary stats. 74 75 Step will not be called after this. 76 """ 77 raise NotImplementedError 78 79 @abstractmethod 80 def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]): 81 """ 82 Called each environment step to update metrics. 83 Args: 84 current_frame (np.ndarray): The current frame rendered by the emulator. 85 recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled. 86 """ 87 raise NotImplementedError 88 89 @abstractmethod 90 def report(self) -> Dict[str, Any]: 91 """ 92 Return metrics as dictionary for instantaneous variable tracking. 93 94 :return: Dictionary of metrics 95 :rtype: Dict[str, Any] 96 """ 97 raise NotImplementedError 98 99 @abstractmethod 100 def report_final(self) -> dict: 101 """ 102 Return metrics as dictionary for logging. Called at end of environment (before close). 103 Will never be called before `self.close`. 104 105 :return: Dictionary of metrics 106 :rtype: Dict[str, Any] 107 """ 108 raise NotImplementedError 109 110 def log_info(self, message: str): 111 """ 112 Logs with MetricGroup's name. Primarily for debugging. 113 """ 114 log_info(f"[Metric({self.NAME})]: {message}", self._parameters) 115 116 def log_warn(self, message: str): 117 """ 118 Logs with MetricGroup's name. Primarily for debugging. 119 """ 120 log_warn(f"[Metric({self.NAME})]: {message}", self._parameters) 121 122 def log_report(self): 123 """ 124 Logs the current metrics report with MetricGroup's name. Primarily for debugging. 125 """ 126 log_info(f"Metric({self.NAME}):\n") 127 log_dict(self.report(), parameters=self._parameters)
Abstract Base class for organizing related metrics.
Documentation Guidlines:
Every subchild should document the following in their class docstrings:
- Reports (List of keys that are present in the return dict of
report) - Final Reports (List of keys that are present in the return dict of
report_final)
The StateParser which implements the minimum required functionality for this MetricGroup to work.
53 def start(self): 54 """ 55 Called once when environment starts. 56 All subclasses should call super() AFTER initializing their own variables. 57 Only variables that will persist across episodes should be initialized here. 58 """ 59 self.reset(first=True)
Called once when environment starts. All subclasses should call super() AFTER initializing their own variables. Only variables that will persist across episodes should be initialized here.
61 @abstractmethod 62 def reset(self, first: bool = False): 63 """Called when environment resets. 64 65 Args: 66 first (bool): Whether this is the first reset of the environment. If True, might need to aggregate metrics into running final totals. 67 """ 68 raise NotImplementedError
Called when environment resets.
Arguments:
- first (bool): Whether this is the first reset of the environment. If True, might need to aggregate metrics into running final totals.
70 @abstractmethod 71 def close(self): 72 """ 73 Called when environment closes. Good for computing summary stats. 74 75 Step will not be called after this. 76 """ 77 raise NotImplementedError
Called when environment closes. Good for computing summary stats.
Step will not be called after this.
79 @abstractmethod 80 def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]): 81 """ 82 Called each environment step to update metrics. 83 Args: 84 current_frame (np.ndarray): The current frame rendered by the emulator. 85 recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled. 86 """ 87 raise NotImplementedError
Called each environment step to update metrics.
Arguments:
- current_frame (np.ndarray): The current frame rendered by the emulator.
- recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
89 @abstractmethod 90 def report(self) -> Dict[str, Any]: 91 """ 92 Return metrics as dictionary for instantaneous variable tracking. 93 94 :return: Dictionary of metrics 95 :rtype: Dict[str, Any] 96 """ 97 raise NotImplementedError
Return metrics as dictionary for instantaneous variable tracking.
Returns
Dictionary of metrics
99 @abstractmethod 100 def report_final(self) -> dict: 101 """ 102 Return metrics as dictionary for logging. Called at end of environment (before close). 103 Will never be called before `self.close`. 104 105 :return: Dictionary of metrics 106 :rtype: Dict[str, Any] 107 """ 108 raise NotImplementedError
Return metrics as dictionary for logging. Called at end of environment (before close).
Will never be called before self.close.
Returns
Dictionary of metrics
110 def log_info(self, message: str): 111 """ 112 Logs with MetricGroup's name. Primarily for debugging. 113 """ 114 log_info(f"[Metric({self.NAME})]: {message}", self._parameters)
Logs with MetricGroup's name. Primarily for debugging.
116 def log_warn(self, message: str): 117 """ 118 Logs with MetricGroup's name. Primarily for debugging. 119 """ 120 log_warn(f"[Metric({self.NAME})]: {message}", self._parameters)
Logs with MetricGroup's name. Primarily for debugging.
122 def log_report(self): 123 """ 124 Logs the current metrics report with MetricGroup's name. Primarily for debugging. 125 """ 126 log_info(f"Metric({self.NAME}):\n") 127 log_dict(self.report(), parameters=self._parameters)
Logs the current metrics report with MetricGroup's name. Primarily for debugging.
130class CoreMetrics(MetricGroup): 131 """ 132 Tracks basic metrics that are guaranteed to be available in state tracker reports for any and all games: 133 134 Reports: 135 - `steps`: Number of steps taken in the episode. 136 - `frame_changed`: Whether the frame has changed since the last step. 137 - `current_frame`: The current frame. 138 - `passed_frames`: All frames that have passed since the last step. 139 140 Final Reports: 141 - `total_episodes`: Total number of episodes completed. 142 - `average_steps_per_episode`: Average number of steps taken per episode. 143 - `max_steps`: Maximum number of steps taken in any episode. 144 - `min_steps`: Minimum number of steps taken in any episode. 145 - `std_steps`: Standard deviation of steps taken across episodes. 146 147 """ 148 149 NAME = "core" 150 151 def start(self): 152 self.steps_per_episode = [] 153 """ List of steps taken in each episode. """ 154 super().start() 155 156 def reset(self, first=False): 157 if not first: 158 self.steps_per_episode.append(self.steps) 159 else: 160 self.steps = 0 161 """ Number of steps taken in the episode. """ 162 self.previous_frame = None 163 """ Previous frame for detecting changes. """ 164 self.current_frame = None 165 """ Current frame. """ 166 self.frame_changed = True 167 """ Whether the frame has changed at all since last step. """ 168 self.passed_frames = None 169 """ Stack of frames since the last step """ 170 171 def close(self): 172 if len(self.steps_per_episode) > 0: 173 total_episodes = len(self.steps_per_episode) 174 average_steps = np.mean(self.steps_per_episode) 175 max_steps = np.max(self.steps_per_episode) 176 min_steps = np.min(self.steps_per_episode) 177 std_steps = np.std(self.steps_per_episode) 178 else: 179 total_episodes = 0 180 average_steps = 0.0 181 max_steps = 0 182 min_steps = 0 183 std_steps = 0.0 184 self.final_metrics = { 185 "total_episodes": int(total_episodes), 186 "average_steps_per_episode": float(average_steps), 187 "max_steps": int(max_steps), 188 "min_steps": int(min_steps), 189 "std_steps": float(std_steps), 190 } 191 192 def step(self, current_frame, recent_frames): 193 self.steps += 1 194 self.current_frame = current_frame 195 self.passed_frames = recent_frames 196 if self.previous_frame is None: 197 self.previous_frame = current_frame 198 self.frame_changed = True 199 else: 200 frame_changed = False 201 comparison_frame = self.previous_frame 202 if recent_frames is None: 203 recent_frames = np.array([current_frame]) 204 for frame in recent_frames: 205 if np.abs(frame - comparison_frame).mean() > EPSILON: 206 frame_changed = True 207 else: 208 frame_changed = False 209 comparison_frame = frame 210 if frame_changed: 211 break 212 self.frame_changed = frame_changed 213 self.previous_frame = current_frame 214 215 def report(self): 216 """ 217 Provides the following metrics: 218 - `steps`: Number of steps taken in the episode. 219 - `frame_changed`: Whether the frame has changed since the last step. 220 - `current_frame`: The current frame. 221 - `passed_frames`: All frames that have passed since the last step. 222 """ 223 return { 224 "steps": self.steps, 225 "frame_changed": self.frame_changed, 226 "current_frame": self.current_frame, 227 "passed_frames": self.passed_frames, 228 } 229 230 def report_final(self): 231 """ 232 Provides the following metrics: 233 - `total_episodes`: Total number of episodes completed. 234 - `average_steps_per_episode`: Average number of steps taken per episode. 235 - `max_steps`: Maximum number of steps taken in any episode. 236 - `min_steps`: Minimum number of steps taken in any episode. 237 - `std_steps`: Standard deviation of steps taken across episodes. 238 """ 239 return self.final_metrics
Tracks basic metrics that are guaranteed to be available in state tracker reports for any and all games:
Reports:
steps: Number of steps taken in the episode.frame_changed: Whether the frame has changed since the last step.current_frame: The current frame.passed_frames: All frames that have passed since the last step.
Final Reports:
total_episodes: Total number of episodes completed.average_steps_per_episode: Average number of steps taken per episode.max_steps: Maximum number of steps taken in any episode.min_steps: Minimum number of steps taken in any episode.std_steps: Standard deviation of steps taken across episodes.
151 def start(self): 152 self.steps_per_episode = [] 153 """ List of steps taken in each episode. """ 154 super().start()
Called once when environment starts. All subclasses should call super() AFTER initializing their own variables. Only variables that will persist across episodes should be initialized here.
156 def reset(self, first=False): 157 if not first: 158 self.steps_per_episode.append(self.steps) 159 else: 160 self.steps = 0 161 """ Number of steps taken in the episode. """ 162 self.previous_frame = None 163 """ Previous frame for detecting changes. """ 164 self.current_frame = None 165 """ Current frame. """ 166 self.frame_changed = True 167 """ Whether the frame has changed at all since last step. """ 168 self.passed_frames = None 169 """ Stack of frames since the last step """
Called when environment resets.
Arguments:
- first (bool): Whether this is the first reset of the environment. If True, might need to aggregate metrics into running final totals.
171 def close(self): 172 if len(self.steps_per_episode) > 0: 173 total_episodes = len(self.steps_per_episode) 174 average_steps = np.mean(self.steps_per_episode) 175 max_steps = np.max(self.steps_per_episode) 176 min_steps = np.min(self.steps_per_episode) 177 std_steps = np.std(self.steps_per_episode) 178 else: 179 total_episodes = 0 180 average_steps = 0.0 181 max_steps = 0 182 min_steps = 0 183 std_steps = 0.0 184 self.final_metrics = { 185 "total_episodes": int(total_episodes), 186 "average_steps_per_episode": float(average_steps), 187 "max_steps": int(max_steps), 188 "min_steps": int(min_steps), 189 "std_steps": float(std_steps), 190 }
Called when environment closes. Good for computing summary stats.
Step will not be called after this.
192 def step(self, current_frame, recent_frames): 193 self.steps += 1 194 self.current_frame = current_frame 195 self.passed_frames = recent_frames 196 if self.previous_frame is None: 197 self.previous_frame = current_frame 198 self.frame_changed = True 199 else: 200 frame_changed = False 201 comparison_frame = self.previous_frame 202 if recent_frames is None: 203 recent_frames = np.array([current_frame]) 204 for frame in recent_frames: 205 if np.abs(frame - comparison_frame).mean() > EPSILON: 206 frame_changed = True 207 else: 208 frame_changed = False 209 comparison_frame = frame 210 if frame_changed: 211 break 212 self.frame_changed = frame_changed 213 self.previous_frame = current_frame
Called each environment step to update metrics.
Arguments:
- current_frame (np.ndarray): The current frame rendered by the emulator.
- recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
215 def report(self): 216 """ 217 Provides the following metrics: 218 - `steps`: Number of steps taken in the episode. 219 - `frame_changed`: Whether the frame has changed since the last step. 220 - `current_frame`: The current frame. 221 - `passed_frames`: All frames that have passed since the last step. 222 """ 223 return { 224 "steps": self.steps, 225 "frame_changed": self.frame_changed, 226 "current_frame": self.current_frame, 227 "passed_frames": self.passed_frames, 228 }
Provides the following metrics:
steps: Number of steps taken in the episode.frame_changed: Whether the frame has changed since the last step.current_frame: The current frame.passed_frames: All frames that have passed since the last step.
230 def report_final(self): 231 """ 232 Provides the following metrics: 233 - `total_episodes`: Total number of episodes completed. 234 - `average_steps_per_episode`: Average number of steps taken per episode. 235 - `max_steps`: Maximum number of steps taken in any episode. 236 - `min_steps`: Minimum number of steps taken in any episode. 237 - `std_steps`: Standard deviation of steps taken across episodes. 238 """ 239 return self.final_metrics
Provides the following metrics:
total_episodes: Total number of episodes completed.average_steps_per_episode: Average number of steps taken per episode.max_steps: Maximum number of steps taken in any episode.min_steps: Minimum number of steps taken in any episode.std_steps: Standard deviation of steps taken across episodes.
Inherited Members
242class OCRegionMetric(MetricGroup, ABC): 243 """ 244 Watch particular screen regions and capture subscreens for OCR when possible. Does not actually perform OCR itself, but makes it easy to capture the relevant regions. 245 Children implementing this must define self.kinds in `start()` and then call on `super().start()`. 246 247 Reports: 248 - `ocr_regions`: A dictionary mapping kinds to captured regions that had OCR-eligible text detected in them. The keys are kinds of OCR regions, and the values are the stacks of captured screen regions as numpy arrays of shape (num_captures, height, width, channels). 249 - `step`: The current step number. Useful for differentiating when multiple OCR texts were found in the same episode. You can typically safely ignore this. 250 251 Final Reports: 252 - `ocr_regions`: A list of tuples for all steps where OCR was detected. Is in form: `List[Tuple[int, Dict[str, np.ndarray]]]` where the int is the step number and the Dict maps kinds to a stack of the captured screen region. 253 254 """ 255 256 NAME = "ocr" 257 258 def start(self): 259 """ 260 Assumes the child has initialized a dict called self.kinds which tracks the various kinds of OCR that could be done. 261 self.kinds should be in the form: {kind: region_name} where region_name is the name of the region to OCR for that kind. 262 Will track ocr captured region results in form of list of dictionaries where these kinds are keys. 263 """ 264 super().start() 265 if self.NAME != "ocr": 266 log_error( 267 "OCRMetric subclasses must have NAME equal to 'ocr' for the environment get_info() aggregation step to work.", 268 self._parameters, 269 ) 270 if not hasattr(self, "kinds"): 271 log_error("OCRMetrics must declare self.kinds dictionary", self._parameters) 272 elif not isinstance(self.kinds, dict): 273 log_error("self.kinds must be a dictionary", self._parameters) 274 self.kinds: dict 275 for item in self.kinds: 276 if not isinstance(item, str): 277 log_error("self.kinds keys must be strings", self._parameters) 278 region_info = self.kinds[item] 279 if not isinstance(region_info, str): 280 log_error( 281 "self.kinds values must be region names (strings)", self._parameters 282 ) 283 if region_info not in self.state_parser.named_screen_regions: 284 log_error( 285 f"OCR region name {region_info} not found in state parser named regions. Available options: {self.state_parser.named_screen_regions}", 286 self._parameters, 287 ) 288 289 @staticmethod 290 def can_read_kind(self, frame: np.ndarray, kind: str) -> bool: 291 """ 292 Checks if the frame has text for the given kind. 293 294 Args: 295 frame (np.ndarray): The frame to check. 296 kind (str): The kind of text to check for. 297 """ 298 raise NotImplementedError 299 300 def reset(self, first=False): 301 """ 302 ocr_regions will track a list of the form List[Tuple[int, Dict[str, np.ndarray]]] 303 which is a list of (step_number, {kind: ocr_region}) dictionaries. 304 """ 305 self.ocr_regions = [] 306 self.steps = 0 307 self.prev_has_ocr = False 308 309 def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]): 310 all_frames = None 311 if recent_frames is not None: 312 all_frames = recent_frames # Current frame is included in recent frames 313 else: 314 all_frames = np.array([current_frame]) 315 ocr_dict = {} 316 # Aggregate results for all frames and separate per kind. 317 for kind in self.kinds.keys(): 318 captured_frames = [] 319 for frame in all_frames: 320 if self.can_read_kind(frame, kind): 321 captured_frames.append( 322 self.state_parser.capture_named_region( 323 current_frame=frame, name=self.kinds[kind] 324 ) 325 ) 326 true_captured_frames = [] # remove duplicates for efficiency 327 # add the later frame in case of overlap detection. This prefers more text for cases where text comes as a stream. 328 if len(captured_frames) > 0: 329 for i in range(len(captured_frames) - 1): 330 curr_frame = captured_frames[i] 331 next_frame = captured_frames[i + 1] 332 if np.abs(curr_frame - next_frame).mean() > EPSILON: 333 true_captured_frames.append(curr_frame) 334 true_captured_frames.append( 335 captured_frames[-1] 336 ) # always add the last frame. 337 ocr_dict[kind] = np.array(true_captured_frames) 338 if len(ocr_dict) > 0: 339 self.ocr_regions.append((self.steps, ocr_dict)) 340 self.prev_has_ocr = True 341 else: 342 self.prev_has_ocr = False 343 self.steps += 1 344 345 def report(self): 346 """ 347 Reports just the previous step's OCR regions if any were found. 348 Returns: 349 dict: A dictionary containing the captured regions for focused OCR. 350 """ 351 if self.prev_has_ocr: 352 return { 353 "ocr_regions": self.ocr_regions[-1][1], 354 "step": self.ocr_regions[-1][0], 355 } 356 else: 357 return {} 358 359 def report_final(self): 360 """ 361 Reports all the OCR regions extracted in the episode. 362 """ 363 return {"ocr_regions": self.ocr_regions} 364 365 def close(self): 366 pass
Watch particular screen regions and capture subscreens for OCR when possible. Does not actually perform OCR itself, but makes it easy to capture the relevant regions.
Children implementing this must define self.kinds in start() and then call on super().start().
Reports:
ocr_regions: A dictionary mapping kinds to captured regions that had OCR-eligible text detected in them. The keys are kinds of OCR regions, and the values are the stacks of captured screen regions as numpy arrays of shape (num_captures, height, width, channels).step: The current step number. Useful for differentiating when multiple OCR texts were found in the same episode. You can typically safely ignore this.
Final Reports:
ocr_regions: A list of tuples for all steps where OCR was detected. Is in form:List[Tuple[int, Dict[str, np.ndarray]]]where the int is the step number and the Dict maps kinds to a stack of the captured screen region.
258 def start(self): 259 """ 260 Assumes the child has initialized a dict called self.kinds which tracks the various kinds of OCR that could be done. 261 self.kinds should be in the form: {kind: region_name} where region_name is the name of the region to OCR for that kind. 262 Will track ocr captured region results in form of list of dictionaries where these kinds are keys. 263 """ 264 super().start() 265 if self.NAME != "ocr": 266 log_error( 267 "OCRMetric subclasses must have NAME equal to 'ocr' for the environment get_info() aggregation step to work.", 268 self._parameters, 269 ) 270 if not hasattr(self, "kinds"): 271 log_error("OCRMetrics must declare self.kinds dictionary", self._parameters) 272 elif not isinstance(self.kinds, dict): 273 log_error("self.kinds must be a dictionary", self._parameters) 274 self.kinds: dict 275 for item in self.kinds: 276 if not isinstance(item, str): 277 log_error("self.kinds keys must be strings", self._parameters) 278 region_info = self.kinds[item] 279 if not isinstance(region_info, str): 280 log_error( 281 "self.kinds values must be region names (strings)", self._parameters 282 ) 283 if region_info not in self.state_parser.named_screen_regions: 284 log_error( 285 f"OCR region name {region_info} not found in state parser named regions. Available options: {self.state_parser.named_screen_regions}", 286 self._parameters, 287 )
Assumes the child has initialized a dict called self.kinds which tracks the various kinds of OCR that could be done. self.kinds should be in the form: {kind: region_name} where region_name is the name of the region to OCR for that kind. Will track ocr captured region results in form of list of dictionaries where these kinds are keys.
289 @staticmethod 290 def can_read_kind(self, frame: np.ndarray, kind: str) -> bool: 291 """ 292 Checks if the frame has text for the given kind. 293 294 Args: 295 frame (np.ndarray): The frame to check. 296 kind (str): The kind of text to check for. 297 """ 298 raise NotImplementedError
Checks if the frame has text for the given kind.
Arguments:
- frame (np.ndarray): The frame to check.
- kind (str): The kind of text to check for.
300 def reset(self, first=False): 301 """ 302 ocr_regions will track a list of the form List[Tuple[int, Dict[str, np.ndarray]]] 303 which is a list of (step_number, {kind: ocr_region}) dictionaries. 304 """ 305 self.ocr_regions = [] 306 self.steps = 0 307 self.prev_has_ocr = False
ocr_regions will track a list of the form List[Tuple[int, Dict[str, np.ndarray]]] which is a list of (step_number, {kind: ocr_region}) dictionaries.
309 def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]): 310 all_frames = None 311 if recent_frames is not None: 312 all_frames = recent_frames # Current frame is included in recent frames 313 else: 314 all_frames = np.array([current_frame]) 315 ocr_dict = {} 316 # Aggregate results for all frames and separate per kind. 317 for kind in self.kinds.keys(): 318 captured_frames = [] 319 for frame in all_frames: 320 if self.can_read_kind(frame, kind): 321 captured_frames.append( 322 self.state_parser.capture_named_region( 323 current_frame=frame, name=self.kinds[kind] 324 ) 325 ) 326 true_captured_frames = [] # remove duplicates for efficiency 327 # add the later frame in case of overlap detection. This prefers more text for cases where text comes as a stream. 328 if len(captured_frames) > 0: 329 for i in range(len(captured_frames) - 1): 330 curr_frame = captured_frames[i] 331 next_frame = captured_frames[i + 1] 332 if np.abs(curr_frame - next_frame).mean() > EPSILON: 333 true_captured_frames.append(curr_frame) 334 true_captured_frames.append( 335 captured_frames[-1] 336 ) # always add the last frame. 337 ocr_dict[kind] = np.array(true_captured_frames) 338 if len(ocr_dict) > 0: 339 self.ocr_regions.append((self.steps, ocr_dict)) 340 self.prev_has_ocr = True 341 else: 342 self.prev_has_ocr = False 343 self.steps += 1
Called each environment step to update metrics.
Arguments:
- current_frame (np.ndarray): The current frame rendered by the emulator.
- recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
345 def report(self): 346 """ 347 Reports just the previous step's OCR regions if any were found. 348 Returns: 349 dict: A dictionary containing the captured regions for focused OCR. 350 """ 351 if self.prev_has_ocr: 352 return { 353 "ocr_regions": self.ocr_regions[-1][1], 354 "step": self.ocr_regions[-1][0], 355 } 356 else: 357 return {}
Reports just the previous step's OCR regions if any were found.
Returns:
dict: A dictionary containing the captured regions for focused OCR.
359 def report_final(self): 360 """ 361 Reports all the OCR regions extracted in the episode. 362 """ 363 return {"ocr_regions": self.ocr_regions}
Reports all the OCR regions extracted in the episode.
Called when environment closes. Good for computing summary stats.
Step will not be called after this.
Inherited Members
369class SubGoal(ABC): 370 """ 371 Abstract class representing a subgoal for tracking progress towards a test goal. These are intermediate states that must be achieved on the way to the final test goal. 372 By convention, the task goal state itself is *not* considered a subgoal, but rather the final goal that the subgoals lead towards. 373 """ 374 375 NAME = "placeholder" 376 """ Name of the subgoal. """ 377 378 def __init__(self): 379 if self.NAME == "placeholder": 380 log_error( 381 "Subclasses of SubGoal must set a unique NAME class variable.", 382 ) 383 self.completed = False 384 385 def check_completed(self, frames: np.ndarray, parser: StateParser) -> bool: 386 """ 387 Checks whether the subgoal has been completed based on the given frames and state parser. 388 389 Args: 390 frames (np.ndarray): The stack of frames to check for subgoal completion. 391 parser (StateParser): The state parser to use for checking subgoal completion. 392 Returns: 393 bool: True if the subgoal is completed, False otherwise. 394 """ 395 for frame in frames: 396 if self._check_completed(frame, parser): 397 return True 398 return False 399 400 @abstractmethod 401 def _check_completed(self, frame: np.ndarray, parser: StateParser) -> bool: 402 """ 403 Checks whether the subgoal has been completed based on a single frame and the state parser. 404 405 Args: 406 frames (np.ndarray): A single frame to check for subgoal completion. 407 parser (StateParser): The state parser to use for checking subgoal completion. 408 Returns: 409 bool: True if the subgoal is completed, False otherwise. 410 """ 411 pass
Abstract class representing a subgoal for tracking progress towards a test goal. These are intermediate states that must be achieved on the way to the final test goal. By convention, the task goal state itself is not considered a subgoal, but rather the final goal that the subgoals lead towards.
385 def check_completed(self, frames: np.ndarray, parser: StateParser) -> bool: 386 """ 387 Checks whether the subgoal has been completed based on the given frames and state parser. 388 389 Args: 390 frames (np.ndarray): The stack of frames to check for subgoal completion. 391 parser (StateParser): The state parser to use for checking subgoal completion. 392 Returns: 393 bool: True if the subgoal is completed, False otherwise. 394 """ 395 for frame in frames: 396 if self._check_completed(frame, parser): 397 return True 398 return False
Checks whether the subgoal has been completed based on the given frames and state parser.
Arguments:
- frames (np.ndarray): The stack of frames to check for subgoal completion.
- parser (StateParser): The state parser to use for checking subgoal completion.
Returns:
bool: True if the subgoal is completed, False otherwise.
414class DummySubGoal(SubGoal): 415 """ 416 A dummy subgoal that is never completed. Useful for testing. 417 """ 418 419 NAME = "dummy_subgoal" 420 421 def _check_completed(self, frame: np.ndarray, parser: StateParser) -> bool: 422 return False
A dummy subgoal that is never completed. Useful for testing.
Inherited Members
425class RegionMatchSubGoal(SubGoal, ABC): 426 """ 427 A subgoal that is completed if a specific region matches a target. Can be used to track subgoals that require specific dialogue boxes to appear, etc. 428 """ 429 430 NAME = "placeholder" 431 _NAMED_REGION: str = None 432 _TARGET_NAME: str = None 433 434 def __init__(self): 435 super().__init__() 436 if self._NAMED_REGION is None or self._TARGET_NAME is None: 437 log_error( 438 "Subclasses of RegionMatchSubGoal must set _NAMED_REGION and _TARGET_NAME class variables.", 439 ) 440 441 def _check_completed(self, frame: np.ndarray, parser: StateParser) -> bool: 442 matches = parser.named_region_matches_multi_target( 443 frame, self._NAMED_REGION, self._TARGET_NAME 444 ) 445 return matches
A subgoal that is completed if a specific region matches a target. Can be used to track subgoals that require specific dialogue boxes to appear, etc.
Inherited Members
448class SingleRegionMatchSubGoal(SubGoal, ABC): 449 """ 450 A subgoal that is completed if a specific single region matches its target. 451 """ 452 453 NAME = "placeholder" 454 _NAMED_REGION: str = None 455 456 def __init__(self): 457 super().__init__() 458 if self._NAMED_REGION is None: 459 log_error( 460 "Subclasses of SingleRegionMatchSubGoal must set _NAMED_REGION class variable.", 461 ) 462 463 def _check_completed(self, frame, parser): 464 matches = parser.named_region_matches_target(frame, self._NAMED_REGION) 465 return matches
A subgoal that is completed if a specific single region matches its target.
Inherited Members
468class AnyRegionMatchSubGoal(SubGoal, ABC): 469 """ 470 A subgoal that is completed if any of a list of specific regions matches their targets. 471 """ 472 473 NAME = "placeholder" 474 _NAMED_REGIONS: List[str] = None 475 _TARGET_NAMES: List[str] = None 476 477 def __init__(self): 478 super().__init__() 479 if ( 480 self._NAMED_REGIONS is None 481 or self._TARGET_NAMES is None 482 or len(self._NAMED_REGIONS) != len(self._TARGET_NAMES) 483 or len(self._NAMED_REGIONS) == 0 484 ): 485 log_error( 486 "Subclasses of AnyRegionMatchSubGoal must set _NAMED_REGIONS and _TARGET_NAMES class variables, and they must be of the same length non zero.", 487 ) 488 489 def _check_completed(self, frame: np.ndarray, parser: StateParser) -> bool: 490 for named_region, target_name in zip(self._NAMED_REGIONS, self._TARGET_NAMES): 491 matches = parser.named_region_matches_multi_target( 492 frame, named_region, target_name 493 ) 494 if matches: 495 return True 496 return False
A subgoal that is completed if any of a list of specific regions matches their targets.
Inherited Members
499class AnySingleRegionMatchSubGoal(SubGoal, ABC): 500 """ 501 A subgoal that is completed if any of a list of specific regions matches their targets, where each region only has one target. 502 """ 503 504 NAME = "placeholder" 505 _NAMED_REGIONS: List[str] = None 506 507 def __init__(self): 508 super().__init__() 509 if self._NAMED_REGIONS is None or len(self._NAMED_REGIONS) == 0: 510 log_error( 511 "Subclasses of AnySingleRegionMatchSubGoal must set _NAMED_REGIONS class variable, and it must be non empty.", 512 ) 513 514 def _check_completed(self, frame: np.ndarray, parser: StateParser) -> bool: 515 for named_region in self._NAMED_REGIONS: 516 matches = parser.named_region_matches_target(frame, named_region) 517 if matches: 518 return True 519 return False
A subgoal that is completed if any of a list of specific regions matches their targets, where each region only has one target.
Inherited Members
522class SubGoalMetric(MetricGroup, ABC): 523 """ 524 Tracks subgoal based progress towards a specific test goal. 525 Subgoals are always sequential, i.e. it is impossible to complete subgoal n+1 without completing subgoal n first. 526 527 Reports: 528 - `all`: A list of the names of all subgoals being tracked, regardless of completion status. 529 - `completed`: A list of the names of the subgoals that have been completed. 530 531 Final Reports: 532 - `reached_subgoals`: List of subgoals that were reached at any point during any episode. 533 """ 534 535 NAME = "subgoals" 536 SUBGOALS: List[SubGoal] = [] 537 """ List of SubGoal classes representing the subgoals to be tracked. These should be defined in child classes. """ 538 539 def start(self): 540 if self.NAME != "subgoals": 541 log_error( 542 f"SubGoalMetric NAME must be 'subgoals', got '{self.NAME}'.", 543 self._parameters, 544 ) 545 if len(self.SUBGOALS) == 0: 546 log_error( 547 "SubGoalMetric requires at least one subgoal to be defined in the SUBGOALS class variable.", 548 self._parameters, 549 ) 550 self._subgoals: List[SubGoal] = [] 551 """ List of SubGoal instances representing the subgoals being tracked. """ 552 for subgoal_class in self.SUBGOALS: 553 subgoal_instance: SubGoal = subgoal_class() 554 self._subgoals.append(subgoal_instance) 555 self._reached_subgoals: Set[str] = set() 556 """ Set of subgoals that were reached at any point during any episode. """ 557 super().start() 558 559 def close(self): 560 pass 561 562 def reset(self, first=False): 563 if not first: 564 for subgoal in self._subgoals: 565 if subgoal.completed: 566 self._reached_subgoals.add(subgoal.NAME) 567 for subgoal in self._subgoals: 568 subgoal.completed = False 569 570 def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]): 571 all_frames = None 572 if recent_frames is not None: 573 all_frames = recent_frames # Current frame is included in recent frames 574 else: 575 all_frames = np.array([current_frame]) 576 for subgoal in self._subgoals: 577 if not subgoal.completed: 578 completed = subgoal.check_completed(all_frames, self.state_parser) 579 subgoal.completed = completed 580 581 def report(self): 582 """ 583 Reports the names of the subgoals being tracked and which ones have been completed. 584 Returns: 585 dict: A dictionary containing the list of all subgoals and the list of completed subgoals. 586 """ 587 return { 588 "all": [subgoal.NAME for subgoal in self._subgoals], 589 "completed": [ 590 subgoal.NAME for subgoal in self._subgoals if subgoal.completed 591 ], 592 } 593 594 def report_final(self): 595 """ 596 Reports the names of the subgoals that were reached at any point during any episode. 597 Returns: 598 dict: A dictionary containing the list of reached subgoals. 599 """ 600 return {"reached_subgoals": list(self._reached_subgoals)}
Tracks subgoal based progress towards a specific test goal. Subgoals are always sequential, i.e. it is impossible to complete subgoal n+1 without completing subgoal n first.
Reports:
all: A list of the names of all subgoals being tracked, regardless of completion status.completed: A list of the names of the subgoals that have been completed.
Final Reports:
reached_subgoals: List of subgoals that were reached at any point during any episode.
List of SubGoal classes representing the subgoals to be tracked. These should be defined in child classes.
539 def start(self): 540 if self.NAME != "subgoals": 541 log_error( 542 f"SubGoalMetric NAME must be 'subgoals', got '{self.NAME}'.", 543 self._parameters, 544 ) 545 if len(self.SUBGOALS) == 0: 546 log_error( 547 "SubGoalMetric requires at least one subgoal to be defined in the SUBGOALS class variable.", 548 self._parameters, 549 ) 550 self._subgoals: List[SubGoal] = [] 551 """ List of SubGoal instances representing the subgoals being tracked. """ 552 for subgoal_class in self.SUBGOALS: 553 subgoal_instance: SubGoal = subgoal_class() 554 self._subgoals.append(subgoal_instance) 555 self._reached_subgoals: Set[str] = set() 556 """ Set of subgoals that were reached at any point during any episode. """ 557 super().start()
Called once when environment starts. All subclasses should call super() AFTER initializing their own variables. Only variables that will persist across episodes should be initialized here.
Called when environment closes. Good for computing summary stats.
Step will not be called after this.
562 def reset(self, first=False): 563 if not first: 564 for subgoal in self._subgoals: 565 if subgoal.completed: 566 self._reached_subgoals.add(subgoal.NAME) 567 for subgoal in self._subgoals: 568 subgoal.completed = False
Called when environment resets.
Arguments:
- first (bool): Whether this is the first reset of the environment. If True, might need to aggregate metrics into running final totals.
570 def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]): 571 all_frames = None 572 if recent_frames is not None: 573 all_frames = recent_frames # Current frame is included in recent frames 574 else: 575 all_frames = np.array([current_frame]) 576 for subgoal in self._subgoals: 577 if not subgoal.completed: 578 completed = subgoal.check_completed(all_frames, self.state_parser) 579 subgoal.completed = completed
Called each environment step to update metrics.
Arguments:
- current_frame (np.ndarray): The current frame rendered by the emulator.
- recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
581 def report(self): 582 """ 583 Reports the names of the subgoals being tracked and which ones have been completed. 584 Returns: 585 dict: A dictionary containing the list of all subgoals and the list of completed subgoals. 586 """ 587 return { 588 "all": [subgoal.NAME for subgoal in self._subgoals], 589 "completed": [ 590 subgoal.NAME for subgoal in self._subgoals if subgoal.completed 591 ], 592 }
Reports the names of the subgoals being tracked and which ones have been completed.
Returns:
dict: A dictionary containing the list of all subgoals and the list of completed subgoals.
594 def report_final(self): 595 """ 596 Reports the names of the subgoals that were reached at any point during any episode. 597 Returns: 598 dict: A dictionary containing the list of reached subgoals. 599 """ 600 return {"reached_subgoals": list(self._reached_subgoals)}
Reports the names of the subgoals that were reached at any point during any episode.
Returns:
dict: A dictionary containing the list of reached subgoals.
Inherited Members
603class DummySubGoalMetric(SubGoalMetric): 604 """ 605 A dummy SubGoalMetric that tracks a single DummySubGoal. Useful for testing. 606 """ 607 608 SUBGOALS = [DummySubGoal]
A dummy SubGoalMetric that tracks a single DummySubGoal. Useful for testing.
List of SubGoal classes representing the subgoals to be tracked. These should be defined in child classes.
611def make_subgoal_metric_class(subgoals: List[Type[SubGoal]]) -> Type[SubGoalMetric]: 612 """ 613 Factory function to create a SubGoalMetric class with the given subgoals and name. 614 615 Args: 616 subgoals (List[Type[SubGoal]]): The list of SubGoal classes to track. 617 name (str): The name of the SubGoalMetric class. 618 619 Returns: 620 Type[SubGoalMetric]: A new SubGoalMetric class with the specified subgoals and name. 621 """ 622 if len(subgoals) == 0: 623 log_error("Must provide at least one subgoal to create a SubGoalMetric class.") 624 625 class CustomSubGoalMetric(SubGoalMetric): 626 SUBGOALS = subgoals 627 628 return CustomSubGoalMetric
Factory function to create a SubGoalMetric class with the given subgoals and name.
Arguments:
- subgoals (List[Type[SubGoal]]): The list of SubGoal classes to track.
- name (str): The name of the SubGoalMetric class.
Returns:
Type[SubGoalMetric]: A new SubGoalMetric class with the specified subgoals and name.
631class TerminationTruncationMetric(MetricGroup, ABC): 632 """ 633 Tracks whether the environment was terminated or truncated. 634 635 Reports: 636 - `terminated`: Whether the environment was terminated. 637 - `truncated`: Whether the environment was truncated. 638 639 Final Reports: 640 - `episode_end_reason`: List of reasons for episode endings: "terminated", "truncated", or None (None will occur only if there is a bug that leads to a premature reset). 641 """ 642 643 NAME = "termination_truncation" 644 645 def start(self): 646 super().start() 647 if self.NAME != "termination_truncation": 648 log_error( 649 f"TerminationTruncationMetric NAME must be 'termination_truncation', got '{self.NAME}'.", 650 self._parameters, 651 ) 652 self.episode_end_reason = [] 653 """ List of reasons for episode: termination or truncation or None (None will occur only if there is a bug that leads to a premature reset). """ 654 self.terminated = False 655 """ Whether the environment was terminated. """ 656 self.truncated = False 657 """ Whether the environment was truncated. """ 658 659 def reset(self, first=False): 660 if not first: 661 if self.terminated: 662 self.episode_end_reason.append("terminated") 663 elif self.truncated: 664 self.episode_end_reason.append("truncated") 665 else: 666 self.episode_end_reason.append(None) 667 self.terminated = False 668 self.truncated = False 669 670 def close(self): 671 pass 672 673 @abstractmethod 674 def determine_truncated( 675 self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray] 676 ) -> bool: 677 """ 678 Determines whether the environment was truncated. 679 680 :param current_frame: The current frame rendered by the emulator. 681 :type current_frame: np.ndarray 682 :param recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled. 683 :type recent_frames: Optional[np.ndarray] 684 :return: True if the environment was truncated, False otherwise. 685 :rtype: bool 686 """ 687 pass 688 689 @abstractmethod 690 def determine_terminated( 691 self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray] 692 ) -> bool: 693 """ 694 Determines whether the environment was terminated. 695 696 :param current_frame: The current frame rendered by the emulator. 697 :type current_frame: np.ndarray 698 :param recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled. 699 :type recent_frames: Optional[np.ndarray] 700 :return: True if the environment was terminated, False otherwise. 701 :rtype: bool 702 """ 703 pass 704 705 def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]): 706 """ 707 Determines whether the environment was terminated or truncated. 708 """ 709 if self.terminated or self.truncated: 710 return # This should ideally not happen, because the environment should reset after termination or truncation. 711 self.truncated = self.determine_truncated(current_frame, recent_frames) 712 self.terminated = self.determine_terminated(current_frame, recent_frames) 713 714 def report(self): 715 """ 716 Reports whether the environment was terminated or truncated. 717 Returns: 718 dict: A dictionary containing the termination and truncation status. 719 """ 720 return { 721 "terminated": self.terminated, 722 "truncated": self.truncated, 723 } 724 725 def report_final(self): 726 """ 727 Reports the reasons for episode endings. 728 Returns: 729 dict: A dictionary containing the list of episode end reasons. 730 """ 731 return {"episode_end_reason": self.episode_end_reason}
Tracks whether the environment was terminated or truncated.
Reports:
terminated: Whether the environment was terminated.truncated: Whether the environment was truncated.
Final Reports:
episode_end_reason: List of reasons for episode endings: "terminated", "truncated", or None (None will occur only if there is a bug that leads to a premature reset).
645 def start(self): 646 super().start() 647 if self.NAME != "termination_truncation": 648 log_error( 649 f"TerminationTruncationMetric NAME must be 'termination_truncation', got '{self.NAME}'.", 650 self._parameters, 651 ) 652 self.episode_end_reason = [] 653 """ List of reasons for episode: termination or truncation or None (None will occur only if there is a bug that leads to a premature reset). """ 654 self.terminated = False 655 """ Whether the environment was terminated. """ 656 self.truncated = False 657 """ Whether the environment was truncated. """
Called once when environment starts. All subclasses should call super() AFTER initializing their own variables. Only variables that will persist across episodes should be initialized here.
659 def reset(self, first=False): 660 if not first: 661 if self.terminated: 662 self.episode_end_reason.append("terminated") 663 elif self.truncated: 664 self.episode_end_reason.append("truncated") 665 else: 666 self.episode_end_reason.append(None) 667 self.terminated = False 668 self.truncated = False
Called when environment resets.
Arguments:
- first (bool): Whether this is the first reset of the environment. If True, might need to aggregate metrics into running final totals.
Called when environment closes. Good for computing summary stats.
Step will not be called after this.
673 @abstractmethod 674 def determine_truncated( 675 self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray] 676 ) -> bool: 677 """ 678 Determines whether the environment was truncated. 679 680 :param current_frame: The current frame rendered by the emulator. 681 :type current_frame: np.ndarray 682 :param recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled. 683 :type recent_frames: Optional[np.ndarray] 684 :return: True if the environment was truncated, False otherwise. 685 :rtype: bool 686 """ 687 pass
Determines whether the environment was truncated.
Parameters
- current_frame: The current frame rendered by the emulator.
- recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
Returns
True if the environment was truncated, False otherwise.
689 @abstractmethod 690 def determine_terminated( 691 self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray] 692 ) -> bool: 693 """ 694 Determines whether the environment was terminated. 695 696 :param current_frame: The current frame rendered by the emulator. 697 :type current_frame: np.ndarray 698 :param recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled. 699 :type recent_frames: Optional[np.ndarray] 700 :return: True if the environment was terminated, False otherwise. 701 :rtype: bool 702 """ 703 pass
Determines whether the environment was terminated.
Parameters
- current_frame: The current frame rendered by the emulator.
- recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
Returns
True if the environment was terminated, False otherwise.
705 def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]): 706 """ 707 Determines whether the environment was terminated or truncated. 708 """ 709 if self.terminated or self.truncated: 710 return # This should ideally not happen, because the environment should reset after termination or truncation. 711 self.truncated = self.determine_truncated(current_frame, recent_frames) 712 self.terminated = self.determine_terminated(current_frame, recent_frames)
Determines whether the environment was terminated or truncated.
714 def report(self): 715 """ 716 Reports whether the environment was terminated or truncated. 717 Returns: 718 dict: A dictionary containing the termination and truncation status. 719 """ 720 return { 721 "terminated": self.terminated, 722 "truncated": self.truncated, 723 }
Reports whether the environment was terminated or truncated.
Returns:
dict: A dictionary containing the termination and truncation status.
725 def report_final(self): 726 """ 727 Reports the reasons for episode endings. 728 Returns: 729 dict: A dictionary containing the list of episode end reasons. 730 """ 731 return {"episode_end_reason": self.episode_end_reason}
Reports the reasons for episode endings.
Returns:
dict: A dictionary containing the list of episode end reasons.
Inherited Members
734class TerminationMetric(TerminationTruncationMetric, ABC): 735 def determine_truncated( 736 self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray] 737 ) -> bool: 738 return False
Tracks whether the environment was terminated or truncated.
Reports:
terminated: Whether the environment was terminated.truncated: Whether the environment was truncated.
Final Reports:
episode_end_reason: List of reasons for episode endings: "terminated", "truncated", or None (None will occur only if there is a bug that leads to a premature reset).
735 def determine_truncated( 736 self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray] 737 ) -> bool: 738 return False
Determines whether the environment was truncated.
Parameters
- current_frame: The current frame rendered by the emulator.
- recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
Returns
True if the environment was truncated, False otherwise.
741class StateTracker: 742 """ 743 Tracks and provides API access to the game state / metrics over time and across episodes. 744 The most hassle-free way to read from the StateTracker is to use the `report()` and `report_final()` methods to get nested dictionaries of all metrics tracked. 745 746 **Example Usage:** 747 748 ```python 749 import numpy as np 750 from gameboy_worlds import get_pokemon_emulator 751 emulator = get_pokemon_emulator(variant="pokemon_red") 752 753 # We can access the StateTracker via the emulator 754 state_tracker = emulator.state_tracker 755 756 # Run a random action on the emulator 757 emulator.reset() 758 allowed_actions = list(LowLevelActions) 759 action = np.random.choice(allowed_actions) 760 _, _ = emulator.step(action) # also updates the StateTracker internally 761 # We can access the current episode metrics via the StateTracker 762 episode_metrics = state_tracker.report() # access all of them as a nested dict 763 specific_metric = state_tracker.get_episode_metric(("core", "steps")) # access specific metrics 764 765 # If we reset the emulator, the StateTracker will reset its inter-episode metrics as well 766 emulator.reset() 767 action = np.random.choice(allowed_actions) 768 _, _ = emulator.step(action) 769 emulator.close() # StateTracker will finalize its metrics internally 770 final_metrics = state_tracker.report_final() # access all of them as a nested dict 771 specific_final_metric = state_tracker.get_final_metric(("core", "average_steps_per_episode")) # access specific final metrics 772 ``` 773 """ 774 775 TERMINATION_TRUNCATION_METRIC: Type[TerminationTruncationMetric] = None 776 """ The TerminationTruncationMetric class to use for tracking termination and truncation. If None, no such metric will be tracked. """ 777 778 SUBGOAL_METRIC: Type[SubGoalMetric] = None 779 """ The SubGoalMetric class to use for tracking subgoal progress. If None, no such metric will be tracked. """ 780 781 def __init__( 782 self, 783 state_parser: StateParser, 784 parameters: dict, 785 ): 786 """ 787 Initializes the StateTracker. 788 Args: 789 state_parser (StateParser): An instance of the StateParser to parse game state variables. 790 parameters (dict): A dictionary of parameters for configuration. 791 """ 792 verify_parameters(parameters) 793 self.state_parser = state_parser 794 """ An instance of the StateParser to parse game state variables. """ 795 self._parameters = parameters 796 self.start() 797 self.validate() 798 if self.metric_classes[0] != CoreMetrics: 799 log_error( 800 "First metric class must be CoreMetrics. Make sure to call `super().start()` first in child class overrides of `start()`.", 801 parameters, 802 ) 803 self.metrics = {} 804 """ Dictionary to store MetricGroup instances. """ 805 for metric_group_class in self.metric_classes: 806 metric_group_instance: MetricGroup = metric_group_class( 807 state_parser, parameters 808 ) 809 self.metrics[metric_group_instance.NAME] = metric_group_instance 810 self.episode_metrics: Dict[str, Dict[str, Any]] = {} 811 """ Dictionary to store metrics running during episode. """ 812 self.final_metrics: Dict[str, Dict[str, Any]] = {} 813 814 def start(self): 815 """ 816 Sets up the metrics for the tracker by creating the list `self.metric_classes` 817 818 Child classes must FIRST call super().start() and THEN set up their own metric classes. 819 """ 820 self.metric_classes: List[Type[MetricGroup]] = [CoreMetrics] 821 if self.TERMINATION_TRUNCATION_METRIC is not None: 822 if not issubclass( 823 self.TERMINATION_TRUNCATION_METRIC, TerminationTruncationMetric 824 ): 825 log_error( 826 "TERMINATION_TRUNCATION_METRIC must be a subclass of TerminationTruncationMetric.", 827 self._parameters, 828 ) 829 self.metric_classes.append(self.TERMINATION_TRUNCATION_METRIC) 830 if self.SUBGOAL_METRIC is not None: 831 if not issubclass(self.SUBGOAL_METRIC, SubGoalMetric): 832 log_error( 833 "SUBGOAL_METRIC must be a subclass of SubGoalMetric.", 834 self._parameters, 835 ) 836 self.metric_classes.append(self.SUBGOAL_METRIC) 837 838 def validate(self): 839 """ 840 Is meant to be called once after initialization to ensure that the tracker is valid. 841 """ 842 pass 843 844 def reset(self): 845 """ 846 Is called once per environment reset to reset any tracked metrics. 847 """ 848 for metric_group in self.metrics.values(): 849 metric_group.reset() 850 self.step() 851 852 def step(self, recent_frames: Optional[np.ndarray] = None): 853 """ 854 Is called once per environment step to update any tracked metrics. 855 856 Args: 857 recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled. 858 epsilon (float, optional): The threshold for considering a frame change. 859 """ 860 current_frame = None 861 if recent_frames is None: 862 current_frame = self.state_parser.get_current_frame() 863 else: 864 current_frame = recent_frames[-1] 865 self.episode_metrics = {} 866 for metric_group in self.metrics.values(): 867 metric_group.step(current_frame, recent_frames) 868 self.episode_metrics[metric_group.NAME] = metric_group.report() 869 870 def close(self): 871 """ 872 Is called once when the environment is closed to finalize any tracked metrics. 873 """ 874 for metric_group in self.metrics.values(): 875 metric_group.close() 876 self.final_metrics = { 877 name: mg.report_final() for name, mg in self.metrics.items() 878 } 879 880 def report(self) -> Dict[str, Dict[str, Any]]: 881 """ 882 Returns the current episode metrics. 883 884 :return: A nested dictionary containing the current episode metrics. 885 :rtype: Dict[str, Dict[str, Any]] 886 """ 887 return self.episode_metrics 888 889 def report_final(self) -> Dict[str, Dict[str, Any]]: 890 """ 891 Returns the final metrics after environment close. 892 893 Returns: 894 Dict[str, Dict[str, Any]]: A nested dictionary containing the final metrics. 895 """ 896 return self.final_metrics 897 898 def _get_specific_metric(self, metrics_dict, key: Tuple[str, str]): 899 if metrics_dict is None: 900 log_error("No metrics available. Have you called step() or close()?") 901 metric_group_name, metric_name = key 902 if metric_group_name not in metrics_dict: 903 log_error( 904 f"Metric group {metric_group_name} not found in metrics. Available groups: {list(metrics_dict.keys())}" 905 ) 906 if metric_name not in metrics_dict[metric_group_name]: 907 log_error( 908 f"Metric {metric_name} not found in metric group {metric_group_name}. Available metrics: {list(metrics_dict[metric_group_name].keys())}" 909 ) 910 return metrics_dict[metric_group_name][metric_name] 911 912 def get_episode_metric(self, key: Tuple[str, str]): 913 """ 914 Returns the metrics for a specific episode and metric group. 915 916 Does not give final metrics at any point. 917 918 :param key: A tuple of the form (metric_group_name, metric_name). 919 :type key: Tuple[str, str] 920 :return: The requested metric value 921 :rtype: Any 922 """ 923 return self._get_specific_metric(self.episode_metrics, key) 924 925 def get_final_metric(self, key: Tuple[str, str]): 926 """ 927 Returns the final metrics for a specific metric group. 928 929 :param key: A tuple of the form (metric_group_name, metric_name). 930 :type key: Tuple[str, str] 931 :return: The requested final metric value 932 :rtype: Any 933 """ 934 return self._get_specific_metric(self.final_metrics, key) 935 936 def __repr__(self) -> str: 937 metric_names = [mg.NAME for mg in self.metrics.values()] 938 return f"<StateTracker, metrics=({', '.join(metric_names)})>"
Tracks and provides API access to the game state / metrics over time and across episodes.
The most hassle-free way to read from the StateTracker is to use the report() and report_final() methods to get nested dictionaries of all metrics tracked.
Example Usage:
import numpy as np
from gameboy_worlds import get_pokemon_emulator
emulator = get_pokemon_emulator(variant="pokemon_red")
# We can access the StateTracker via the emulator
state_tracker = emulator.state_tracker
# Run a random action on the emulator
emulator.reset()
allowed_actions = list(LowLevelActions)
action = np.random.choice(allowed_actions)
_, _ = emulator.step(action) # also updates the StateTracker internally
# We can access the current episode metrics via the StateTracker
episode_metrics = state_tracker.report() # access all of them as a nested dict
specific_metric = state_tracker.get_episode_metric(("core", "steps")) # access specific metrics
# If we reset the emulator, the StateTracker will reset its inter-episode metrics as well
emulator.reset()
action = np.random.choice(allowed_actions)
_, _ = emulator.step(action)
emulator.close() # StateTracker will finalize its metrics internally
final_metrics = state_tracker.report_final() # access all of them as a nested dict
specific_final_metric = state_tracker.get_final_metric(("core", "average_steps_per_episode")) # access specific final metrics
781 def __init__( 782 self, 783 state_parser: StateParser, 784 parameters: dict, 785 ): 786 """ 787 Initializes the StateTracker. 788 Args: 789 state_parser (StateParser): An instance of the StateParser to parse game state variables. 790 parameters (dict): A dictionary of parameters for configuration. 791 """ 792 verify_parameters(parameters) 793 self.state_parser = state_parser 794 """ An instance of the StateParser to parse game state variables. """ 795 self._parameters = parameters 796 self.start() 797 self.validate() 798 if self.metric_classes[0] != CoreMetrics: 799 log_error( 800 "First metric class must be CoreMetrics. Make sure to call `super().start()` first in child class overrides of `start()`.", 801 parameters, 802 ) 803 self.metrics = {} 804 """ Dictionary to store MetricGroup instances. """ 805 for metric_group_class in self.metric_classes: 806 metric_group_instance: MetricGroup = metric_group_class( 807 state_parser, parameters 808 ) 809 self.metrics[metric_group_instance.NAME] = metric_group_instance 810 self.episode_metrics: Dict[str, Dict[str, Any]] = {} 811 """ Dictionary to store metrics running during episode. """ 812 self.final_metrics: Dict[str, Dict[str, Any]] = {}
Initializes the StateTracker.
Arguments:
- state_parser (StateParser): An instance of the StateParser to parse game state variables.
- parameters (dict): A dictionary of parameters for configuration.
The TerminationTruncationMetric class to use for tracking termination and truncation. If None, no such metric will be tracked.
The SubGoalMetric class to use for tracking subgoal progress. If None, no such metric will be tracked.
814 def start(self): 815 """ 816 Sets up the metrics for the tracker by creating the list `self.metric_classes` 817 818 Child classes must FIRST call super().start() and THEN set up their own metric classes. 819 """ 820 self.metric_classes: List[Type[MetricGroup]] = [CoreMetrics] 821 if self.TERMINATION_TRUNCATION_METRIC is not None: 822 if not issubclass( 823 self.TERMINATION_TRUNCATION_METRIC, TerminationTruncationMetric 824 ): 825 log_error( 826 "TERMINATION_TRUNCATION_METRIC must be a subclass of TerminationTruncationMetric.", 827 self._parameters, 828 ) 829 self.metric_classes.append(self.TERMINATION_TRUNCATION_METRIC) 830 if self.SUBGOAL_METRIC is not None: 831 if not issubclass(self.SUBGOAL_METRIC, SubGoalMetric): 832 log_error( 833 "SUBGOAL_METRIC must be a subclass of SubGoalMetric.", 834 self._parameters, 835 ) 836 self.metric_classes.append(self.SUBGOAL_METRIC)
Sets up the metrics for the tracker by creating the list self.metric_classes
Child classes must FIRST call super().start() and THEN set up their own metric classes.
838 def validate(self): 839 """ 840 Is meant to be called once after initialization to ensure that the tracker is valid. 841 """ 842 pass
Is meant to be called once after initialization to ensure that the tracker is valid.
844 def reset(self): 845 """ 846 Is called once per environment reset to reset any tracked metrics. 847 """ 848 for metric_group in self.metrics.values(): 849 metric_group.reset() 850 self.step()
Is called once per environment reset to reset any tracked metrics.
852 def step(self, recent_frames: Optional[np.ndarray] = None): 853 """ 854 Is called once per environment step to update any tracked metrics. 855 856 Args: 857 recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled. 858 epsilon (float, optional): The threshold for considering a frame change. 859 """ 860 current_frame = None 861 if recent_frames is None: 862 current_frame = self.state_parser.get_current_frame() 863 else: 864 current_frame = recent_frames[-1] 865 self.episode_metrics = {} 866 for metric_group in self.metrics.values(): 867 metric_group.step(current_frame, recent_frames) 868 self.episode_metrics[metric_group.NAME] = metric_group.report()
Is called once per environment step to update any tracked metrics.
Arguments:
- recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
- epsilon (float, optional): The threshold for considering a frame change.
870 def close(self): 871 """ 872 Is called once when the environment is closed to finalize any tracked metrics. 873 """ 874 for metric_group in self.metrics.values(): 875 metric_group.close() 876 self.final_metrics = { 877 name: mg.report_final() for name, mg in self.metrics.items() 878 }
Is called once when the environment is closed to finalize any tracked metrics.
880 def report(self) -> Dict[str, Dict[str, Any]]: 881 """ 882 Returns the current episode metrics. 883 884 :return: A nested dictionary containing the current episode metrics. 885 :rtype: Dict[str, Dict[str, Any]] 886 """ 887 return self.episode_metrics
Returns the current episode metrics.
Returns
A nested dictionary containing the current episode metrics.
889 def report_final(self) -> Dict[str, Dict[str, Any]]: 890 """ 891 Returns the final metrics after environment close. 892 893 Returns: 894 Dict[str, Dict[str, Any]]: A nested dictionary containing the final metrics. 895 """ 896 return self.final_metrics
Returns the final metrics after environment close.
Returns:
Dict[str, Dict[str, Any]]: A nested dictionary containing the final metrics.
912 def get_episode_metric(self, key: Tuple[str, str]): 913 """ 914 Returns the metrics for a specific episode and metric group. 915 916 Does not give final metrics at any point. 917 918 :param key: A tuple of the form (metric_group_name, metric_name). 919 :type key: Tuple[str, str] 920 :return: The requested metric value 921 :rtype: Any 922 """ 923 return self._get_specific_metric(self.episode_metrics, key)
Returns the metrics for a specific episode and metric group.
Does not give final metrics at any point.
Parameters
- key: A tuple of the form (metric_group_name, metric_name).
Returns
The requested metric value
925 def get_final_metric(self, key: Tuple[str, str]): 926 """ 927 Returns the final metrics for a specific metric group. 928 929 :param key: A tuple of the form (metric_group_name, metric_name). 930 :type key: Tuple[str, str] 931 :return: The requested final metric value 932 :rtype: Any 933 """ 934 return self._get_specific_metric(self.final_metrics, key)
Returns the final metrics for a specific metric group.
Parameters
- key: A tuple of the form (metric_group_name, metric_name).
Returns
The requested final metric value
941class TestTrackerMixin: 942 """ 943 Mixin class for testing trackers. 944 Ensures that exactly one of the tracked metrics is a TerminationTruncationMetric. 945 """ 946 947 def validate(self): 948 if not hasattr(self, "_parameters"): 949 log_error("Parameters have not been set yet.") 950 if self.TERMINATION_TRUNCATION_METRIC is None: 951 log_error( 952 "TestTrackerMixin requires a TerminationTruncationMetric to be set as TERMINATION_TRUNCATION_METRIC.", 953 self._parameters, 954 ) 955 if self.SUBGOAL_METRIC is None: 956 log_error( 957 "TestTrackerMixin requires a SubGoalMetric to be set as SUBGOAL_METRIC.", 958 self._parameters, 959 )
Mixin class for testing trackers. Ensures that exactly one of the tracked metrics is a TerminationTruncationMetric.
947 def validate(self): 948 if not hasattr(self, "_parameters"): 949 log_error("Parameters have not been set yet.") 950 if self.TERMINATION_TRUNCATION_METRIC is None: 951 log_error( 952 "TestTrackerMixin requires a TerminationTruncationMetric to be set as TERMINATION_TRUNCATION_METRIC.", 953 self._parameters, 954 ) 955 if self.SUBGOAL_METRIC is None: 956 log_error( 957 "TestTrackerMixin requires a SubGoalMetric to be set as SUBGOAL_METRIC.", 958 self._parameters, 959 )
962class RegionMatchTruncationMetric(TerminationTruncationMetric, ABC): 963 """ 964 Truncates the episode if a specific region matches a target. 965 Can be used to truncate episodes when specific dialogue boxes appear, etc. 966 """ 967 968 _TRUNCATION_NAMED_REGION = None 969 _TRUNCATION_TARGET_NAME = None 970 971 def determine_truncated(self, current_frame, recent_frames): 972 if ( 973 self._TRUNCATION_NAMED_REGION is None 974 or self._TRUNCATION_TARGET_NAME is None 975 ): 976 log_error( 977 "Must set _TRUNCATION_NAMED_REGION and _TRUNCATION_TARGET_NAME.", 978 self._parameters, 979 ) 980 all_frames = [current_frame] 981 if recent_frames is not None: 982 all_frames = recent_frames 983 for frame in all_frames: 984 matches = self.state_parser.named_region_matches_multi_target( 985 frame, 986 self._TRUNCATION_NAMED_REGION, 987 self._TRUNCATION_TARGET_NAME, 988 ) 989 if matches: 990 return True 991 return False
Truncates the episode if a specific region matches a target. Can be used to truncate episodes when specific dialogue boxes appear, etc.
971 def determine_truncated(self, current_frame, recent_frames): 972 if ( 973 self._TRUNCATION_NAMED_REGION is None 974 or self._TRUNCATION_TARGET_NAME is None 975 ): 976 log_error( 977 "Must set _TRUNCATION_NAMED_REGION and _TRUNCATION_TARGET_NAME.", 978 self._parameters, 979 ) 980 all_frames = [current_frame] 981 if recent_frames is not None: 982 all_frames = recent_frames 983 for frame in all_frames: 984 matches = self.state_parser.named_region_matches_multi_target( 985 frame, 986 self._TRUNCATION_NAMED_REGION, 987 self._TRUNCATION_TARGET_NAME, 988 ) 989 if matches: 990 return True 991 return False
Determines whether the environment was truncated.
Parameters
- current_frame: The current frame rendered by the emulator.
- recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
Returns
True if the environment was truncated, False otherwise.
994class RegionMatchTerminationMetric(TerminationTruncationMetric, ABC): 995 """ 996 Terminates the episode if a specific region matches a target. 997 Can be used to terminate episodes when specific dialogue boxes appear, etc. 998 """ 999 1000 _TERMINATION_NAMED_REGION = None 1001 _TERMINATION_TARGET_NAME = None 1002 1003 def determine_terminated(self, current_frame, recent_frames): 1004 if ( 1005 self._TERMINATION_NAMED_REGION is None 1006 or self._TERMINATION_TARGET_NAME is None 1007 ): 1008 log_error( 1009 "Must set _TERMINATION_NAMED_REGION and _TERMINATION_TARGET_NAME.", 1010 self._parameters, 1011 ) 1012 all_frames = [current_frame] 1013 if recent_frames is not None: 1014 all_frames = recent_frames 1015 for frame in all_frames: 1016 matches = self.state_parser.named_region_matches_multi_target( 1017 frame, 1018 self._TERMINATION_NAMED_REGION, 1019 self._TERMINATION_TARGET_NAME, 1020 ) 1021 if matches: 1022 return True 1023 return False
Terminates the episode if a specific region matches a target. Can be used to terminate episodes when specific dialogue boxes appear, etc.
1003 def determine_terminated(self, current_frame, recent_frames): 1004 if ( 1005 self._TERMINATION_NAMED_REGION is None 1006 or self._TERMINATION_TARGET_NAME is None 1007 ): 1008 log_error( 1009 "Must set _TERMINATION_NAMED_REGION and _TERMINATION_TARGET_NAME.", 1010 self._parameters, 1011 ) 1012 all_frames = [current_frame] 1013 if recent_frames is not None: 1014 all_frames = recent_frames 1015 for frame in all_frames: 1016 matches = self.state_parser.named_region_matches_multi_target( 1017 frame, 1018 self._TERMINATION_NAMED_REGION, 1019 self._TERMINATION_TARGET_NAME, 1020 ) 1021 if matches: 1022 return True 1023 return False
Determines whether the environment was terminated.
Parameters
- current_frame: The current frame rendered by the emulator.
- recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
Returns
True if the environment was terminated, False otherwise.
1026class RegionMatchTerminationOnlyMetric(TerminationMetric, ABC): 1027 """ 1028 RegionMatchTerminationMetric with no truncation. 1029 No truncation. 1030 """ 1031 1032 _TERMINATION_NAMED_REGION = None 1033 _TERMINATION_TARGET_NAME = None 1034 1035 def determine_terminated(self, current_frame, recent_frames): 1036 if ( 1037 self._TERMINATION_NAMED_REGION is None 1038 or self._TERMINATION_TARGET_NAME is None 1039 ): 1040 log_error( 1041 "Must set _TERMINATION_NAMED_REGION and _TERMINATION_TARGET_NAME.", 1042 self._parameters, 1043 ) 1044 all_frames = [current_frame] 1045 if recent_frames is not None: 1046 all_frames = recent_frames 1047 for frame in all_frames: 1048 matches = self.state_parser.named_region_matches_multi_target( 1049 frame, 1050 self._TERMINATION_NAMED_REGION, 1051 self._TERMINATION_TARGET_NAME, 1052 ) 1053 if matches: 1054 return True 1055 return False
RegionMatchTerminationMetric with no truncation. No truncation.
1035 def determine_terminated(self, current_frame, recent_frames): 1036 if ( 1037 self._TERMINATION_NAMED_REGION is None 1038 or self._TERMINATION_TARGET_NAME is None 1039 ): 1040 log_error( 1041 "Must set _TERMINATION_NAMED_REGION and _TERMINATION_TARGET_NAME.", 1042 self._parameters, 1043 ) 1044 all_frames = [current_frame] 1045 if recent_frames is not None: 1046 all_frames = recent_frames 1047 for frame in all_frames: 1048 matches = self.state_parser.named_region_matches_multi_target( 1049 frame, 1050 self._TERMINATION_NAMED_REGION, 1051 self._TERMINATION_TARGET_NAME, 1052 ) 1053 if matches: 1054 return True 1055 return False
Determines whether the environment was terminated.
Parameters
- current_frame: The current frame rendered by the emulator.
- recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
Returns
True if the environment was terminated, False otherwise.
1057class AnyRegionMatchTerminationMetric(TerminationMetric, ABC): 1058 """ 1059 Terminates the episode if any of a list of specific regions matches their targets. 1060 No truncation. 1061 """ 1062 1063 _NAMED_REGIONS: List[str] = None 1064 _TARGET_NAMES: List[str] = None 1065 1066 def __init__(self): 1067 super().__init__() 1068 if ( 1069 self._NAMED_REGIONS is None 1070 or self._TARGET_NAMES is None 1071 or len(self._NAMED_REGIONS) != len(self._TARGET_NAMES) 1072 or len(self._NAMED_REGIONS) == 0 1073 ): 1074 log_error( 1075 "Subclasses of AnyRegionMatchTerminationMetric must set _NAMED_REGIONS and _TARGET_NAMES class variables, and they must be of the same length non zero.", 1076 ) 1077 1078 def determine_terminated(self, current_frame, recent_frames): 1079 all_frames = [current_frame] 1080 if recent_frames is not None: 1081 all_frames = recent_frames 1082 for frame in all_frames: 1083 for named_region, target_name in zip(self._NAMED_REGIONS, self._TARGET_NAMES): 1084 if self.state_parser.named_region_matches_multi_target( 1085 frame, named_region, target_name 1086 ): 1087 return True 1088 return False
Terminates the episode if any of a list of specific regions matches their targets. No truncation.
1078 def determine_terminated(self, current_frame, recent_frames): 1079 all_frames = [current_frame] 1080 if recent_frames is not None: 1081 all_frames = recent_frames 1082 for frame in all_frames: 1083 for named_region, target_name in zip(self._NAMED_REGIONS, self._TARGET_NAMES): 1084 if self.state_parser.named_region_matches_multi_target( 1085 frame, named_region, target_name 1086 ): 1087 return True 1088 return False
Determines whether the environment was terminated.
Parameters
- current_frame: The current frame rendered by the emulator.
- recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
Returns
True if the environment was terminated, False otherwise.
1091class RegionChangedTerminationMetric(TerminationTruncationMetric, ABC): 1092 """ 1093 Terminates the episode if a specific named region changes significantly 1094 from its appearance at the start of the episode (on reset). 1095 1096 Useful for detecting pickups, stat changes, or any event that alters a 1097 HUD region without having a fixed reference capture. 1098 1099 Subclass and set: 1100 _CHANGED_NAMED_REGION: name of the NamedScreenRegion to monitor 1101 _CHANGE_MAE_THRESHOLD: MAE threshold above which the region is considered changed (default 10) 1102 """ 1103 1104 _CHANGED_NAMED_REGION = None 1105 _CHANGE_MAE_THRESHOLD = 10 1106 1107 def reset(self, first=False): 1108 super().reset(first=first) 1109 self._region_baseline = None 1110 1111 def determine_truncated(self, current_frame, recent_frames): 1112 return False 1113 1114 def determine_terminated(self, current_frame, recent_frames): 1115 if self._CHANGED_NAMED_REGION is None: 1116 log_error("Must set _CHANGED_NAMED_REGION.", self._parameters) 1117 cropped = self.state_parser.capture_named_region( 1118 current_frame, self._CHANGED_NAMED_REGION 1119 ) 1120 if self._region_baseline is None: 1121 self._region_baseline = cropped.copy() 1122 return False 1123 mae = np.abs(cropped.astype(float) - self._region_baseline.astype(float)).mean() 1124 return mae > self._CHANGE_MAE_THRESHOLD
Terminates the episode if a specific named region changes significantly from its appearance at the start of the episode (on reset).
Useful for detecting pickups, stat changes, or any event that alters a HUD region without having a fixed reference capture.
Subclass and set:
_CHANGED_NAMED_REGION: name of the NamedScreenRegion to monitor _CHANGE_MAE_THRESHOLD: MAE threshold above which the region is considered changed (default 10)
1107 def reset(self, first=False): 1108 super().reset(first=first) 1109 self._region_baseline = None
Called when environment resets.
Arguments:
- first (bool): Whether this is the first reset of the environment. If True, might need to aggregate metrics into running final totals.
Determines whether the environment was truncated.
Parameters
- current_frame: The current frame rendered by the emulator.
- recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
Returns
True if the environment was truncated, False otherwise.
1114 def determine_terminated(self, current_frame, recent_frames): 1115 if self._CHANGED_NAMED_REGION is None: 1116 log_error("Must set _CHANGED_NAMED_REGION.", self._parameters) 1117 cropped = self.state_parser.capture_named_region( 1118 current_frame, self._CHANGED_NAMED_REGION 1119 ) 1120 if self._region_baseline is None: 1121 self._region_baseline = cropped.copy() 1122 return False 1123 mae = np.abs(cropped.astype(float) - self._region_baseline.astype(float)).mean() 1124 return mae > self._CHANGE_MAE_THRESHOLD
Determines whether the environment was terminated.
Parameters
- current_frame: The current frame rendered by the emulator.
- recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
Returns
True if the environment was terminated, False otherwise.