From 2b0c529e420eaeacd37119aee8dfc6c9acf28f64 Mon Sep 17 00:00:00 2001 From: AymenFJA Date: Fri, 30 Jan 2026 19:20:50 +0000 Subject: [PATCH 01/30] adding basic core service setup --- pyproject.toml | 3 + rose/service/__init__.py | 3 + rose/service/api/__init__.py | 0 rose/service/api/cli.py | 107 ++++++++++++ rose/service/client.py | 94 +++++++++++ rose/service/manager.py | 314 +++++++++++++++++++++++++++++++++++ rose/service/models.py | 40 +++++ 7 files changed, 561 insertions(+) create mode 100644 rose/service/__init__.py create mode 100644 rose/service/api/__init__.py create mode 100644 rose/service/api/cli.py create mode 100644 rose/service/client.py create mode 100644 rose/service/manager.py create mode 100644 rose/service/models.py diff --git a/pyproject.toml b/pyproject.toml index 9c111bf1..78d3d88d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,9 @@ Homepage = "https://github.com/radical-cybertools/ROSE" Issues = "https://github.com/radical-cybertools/ROSE/issues" Documentation = "https://radical-cybertools.github.io/ROSE/" +[project.scripts] +rose = "rose.service.api.cli:main" + [project.optional-dependencies] lint = ["ruff"] diff --git a/rose/service/__init__.py b/rose/service/__init__.py new file mode 100644 index 00000000..ee1f1f9d --- /dev/null +++ b/rose/service/__init__.py @@ -0,0 +1,3 @@ +from .models import WorkflowState, Workflow + +__all__ = ["WorkflowState", "Workflow"] diff --git a/rose/service/api/__init__.py b/rose/service/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rose/service/api/cli.py b/rose/service/api/cli.py new file mode 100644 index 00000000..7980dd39 --- /dev/null +++ b/rose/service/api/cli.py @@ -0,0 +1,107 @@ +import argparse +import asyncio +import os +import sys +import json +from pathlib import Path + +from rose.service.manager import ServiceManager +from rose.service.client import ServiceClient + +def get_job_id(): + """Get SLURM_JOB_ID from env or argument.""" + # Priority: env var -> but for client, user might validly pass it as arg. + # For 'launch', we usually rely on env var if running inside job. + return os.environ.get("SLURM_JOB_ID", "local_job_0") + +def cmd_launch(args): + """Start the Service Manager.""" + job_id = args.job_id or get_job_id() + print(f"Launching ROSE Service for Job ID: {job_id}") + + manager = ServiceManager(job_id) + try: + asyncio.run(manager.run()) + except KeyboardInterrupt: + print("Service stopping...") + # graceful shutdown could be added here + +def cmd_submit(args): + """Submit a workflow.""" + job_id = args.job_id or get_job_id() + client = ServiceClient(job_id) + + try: + req_id = client.submit_workflow(args.workflow_file) + print(f"Submitted workflow request. Request ID: {req_id}") + print(f"Note: This is the request ID. The Workflow ID (wf_id) will be assigned by the service.") + except Exception as e: + print(f"Error submitting workflow: {e}") + sys.exit(1) + +def cmd_cancel(args): + """Cancel a workflow.""" + job_id = args.job_id or get_job_id() + client = ServiceClient(job_id) + + try: + req_id = client.cancel_workflow(args.wf_id) + print(f"Sent cancellation request for {args.wf_id}. Request ID: {req_id}") + except Exception as e: + print(f"Error cancelling workflow: {e}") + sys.exit(1) + +def cmd_status(args): + """Get status.""" + job_id = args.job_id or get_job_id() + client = ServiceClient(job_id) + + try: + if args.wf_id: + status = client.get_workflow_status(args.wf_id) + if status: + print(json.dumps(status, indent=2)) + else: + print(f"Workflow {args.wf_id} not found.") + else: + # List all + registry = client.list_workflows() + print(f"Workflows in Job {job_id}:") + for wfid, wfdata in registry.items(): + print(f" - {wfid}: {wfdata.get('state')}") + except Exception as e: + print(f"Error getting status: {e}") + sys.exit(1) + +def main(): + parser = argparse.ArgumentParser(description="ROSE Service CLI") + subparsers = parser.add_subparsers(dest="command", required=True) + + # Common arg for job id + parent_parser = argparse.ArgumentParser(add_help=False) + parent_parser.add_argument("--job-id", help="SLURM Job ID (default: $SLURM_JOB_ID)") + + # Launch + p_launch = subparsers.add_parser("launch", parents=[parent_parser], help="Start the Service Manager daemon") + p_launch.set_defaults(func=cmd_launch) + + # Submit + p_submit = subparsers.add_parser("submit", parents=[parent_parser], help="Submit a workflow") + p_submit.add_argument("workflow_file", help="Path to workflow YAML file") + p_submit.set_defaults(func=cmd_submit) + + # Cancel + p_cancel = subparsers.add_parser("cancel", parents=[parent_parser], help="Cancel a workflow") + p_cancel.add_argument("wf_id", help="Workflow ID to cancel") + p_cancel.set_defaults(func=cmd_cancel) + + # Status + p_status = subparsers.add_parser("status", parents=[parent_parser], help="Get workflow status") + p_status.add_argument("wf_id", nargs="?", help="Optional Workflow ID") + p_status.set_defaults(func=cmd_status) + + args = parser.parse_args() + args.func(args) + +if __name__ == "__main__": + main() diff --git a/rose/service/client.py b/rose/service/client.py new file mode 100644 index 00000000..3ce92b96 --- /dev/null +++ b/rose/service/client.py @@ -0,0 +1,94 @@ +import json +import uuid +import time +from pathlib import Path +from typing import Any, Dict, Optional, List + +class ServiceClient: + """Client for interacting with the ROSE Service via File-based IPC. + + Attributes: + job_id (str): The SLURM job ID where the service is running. + service_root (Path): Root directory for service IPC (~/.rose/services/). + """ + + def __init__(self, job_id: str): + self.job_id = job_id + self.service_root = Path.home() / ".rose" / "services" / str(job_id) + self.requests_dir = self.service_root / "requests" + self.registry_file = self.service_root / "registry.json" + + if not self.service_root.exists(): + # It's possible the service hasn't started creating dirs yet, + # or the job ID is wrong. We don't raise immediately to allow + # retry logic in scripts, but warn if needed. + print(f"Warning: Service root {self.service_root} does not exist yet.") + + def _write_request(self, action: str, payload: Dict[str, Any]) -> str: + """Write a request file to the requests directory.""" + req_id = str(uuid.uuid4()) + request_data = { + "id": req_id, + "action": action, + "timestamp": time.time(), + "payload": payload + } + + # Ensure requests dir exists (client might start before service creates it? + # Better to assume service creates it, but safe to check) + if not self.requests_dir.exists(): + raise RuntimeError(f"Service requests directory not found: {self.requests_dir}") + + req_file = self.requests_dir / f"{action}_{req_id}.json" + with open(req_file, "w") as f: + json.dump(request_data, f, indent=2) + + return req_id + + def submit_workflow(self, workflow_file: str) -> str: + """Submit a workflow file to the service. + + Args: + workflow_file (str): Path to the workflow YAML file. + + Returns: + str: Request ID (not yet the wf_id, which depends on service processing). + """ + abs_path = str(Path(workflow_file).resolve()) + return self._write_request("submit", {"workflow_file": abs_path}) + + def cancel_workflow(self, wf_id: str) -> str: + """Request cancellation of a workflow. + + Args: + wf_id (str): The workflow ID to cancel. + """ + return self._write_request("cancel", {"wf_id": wf_id}) + + def get_workflow_status(self, wf_id: str) -> Optional[Dict[str, Any]]: + """Get the current status of a workflow from the registry. + + Args: + wf_id (str): Workflow ID. + + Returns: + dict: Workflow state dict or None if not found. + """ + registry = self._read_registry() + return registry.get(wf_id) + + def list_workflows(self) -> Dict[str, Any]: + """List all workflows in the registry.""" + return self._read_registry() + + def _read_registry(self) -> Dict[str, Any]: + """Read and parse the registry file.""" + if not self.registry_file.exists(): + return {} + + try: + with open(self.registry_file, "r") as f: + return json.load(f) + except (json.JSONDecodeError, FileNotFoundError): + # Race condition on read or empty file + return {} diff --git a/rose/service/manager.py b/rose/service/manager.py new file mode 100644 index 00000000..850e8d71 --- /dev/null +++ b/rose/service/manager.py @@ -0,0 +1,314 @@ +import asyncio +import json +import os +import shutil +import importlib.util +import sys +import logging +from pathlib import Path +from typing import Any, Dict, Optional, List, Callable + +from radical.asyncflow import WorkflowEngine, ConcurrentExecutionBackend +from concurrent.futures import ProcessPoolExecutor + +from rose.al.active_learner import SequentialActiveLearner +from rose.learner import LearnerConfig, TaskConfig +from .models import Workflow, WorkflowState + +logger = logging.getLogger(__name__) + +class WorkflowLoader: + """Helper to load a Learner from a YAML definition.""" + + @staticmethod + def load_yaml(path: str) -> Dict[str, Any]: + """Load YAML file (mocking yaml load with json for now or basic parsing if yaml lib not avail? + User environment might have PyYAML. Assuming yaml is available or using json for simplicity if needed. + The user request says 'workflow.yaml', so we should try to support YAML. + If PyYAML is not installed, we might fallback or error. + Standard python doesn't have yaml. + """ + # For this implementation, I will assume PyYAML is available as it's common in this stack, + # or I will implement a very simple parser if restricted. + # Given "ROSE" context, PyYAML is likely a dependency. + # But to be safe and depend only on stdlib as requested ("standard libraries only" was for IPC, but let's stick to it), + # I will check if yaml module exists, otherwise parsing simple key-value or use JSON. + # However, the user explicitly said "workflow.yaml". + # I will try to import yaml. + try: + import yaml + with open(path, "r") as f: + return yaml.safe_load(f) + except ImportError: + # Fallback: simpler parsing or expect JSON content in .yaml (not ideal) + logger.warning("PyYAML not found, trying JSON parsing for workflow file") + with open(path, "r") as f: + return json.load(f) + + @staticmethod + def _import_function(path_str: str) -> Callable: + """Import a function from a module path string 'package.module.func'.""" + try: + module_name, func_name = path_str.rsplit(".", 1) + module = importlib.import_module(module_name) + return getattr(module, func_name) + except (ValueError, ImportError, AttributeError) as e: + raise ImportError(f"Could not import function '{path_str}': {e}") + + @staticmethod + def _create_script_task_factory(script_path: str) -> Callable: + """Create a task function that returns the script path + arguments. + + Args: + script_path: The base command or script path. + """ + async def task_func(*args, **kwargs): + # Extract string arguments to append to the command. + # Skip Task objects (dependencies). + cmd_parts = [script_path] + for arg in args: + if isinstance(arg, str): + cmd_parts.append(arg) + + for k, v in kwargs.items(): + if isinstance(v, bool): + if v: cmd_parts.append(f"--{k}") + else: + cmd_parts.append(f"--{k} {v}") + + print(f"Task command: {' '.join(cmd_parts)}") + return " ".join(cmd_parts) + return task_func + + @classmethod + def create_learner(cls, wf_id: str, workflow_def: Dict[str, Any], asyncflow: WorkflowEngine) -> SequentialActiveLearner: + """Create and configure a SequentialActiveLearner.""" + + # 1. Create Learner + learner = SequentialActiveLearner(asyncflow) + learner.learner_id = wf_id # Using wf_id (str) might need adaptation if learner_id expects int in some places? + # rose/active_learner.py: learner_id (Optional[int]). + # I should probably hash the wf_id or just set it if it accepts Any? + # The type hint says Optional[int]. Let's ignore type hint for a moment or hash it. + learner.learner_id = hash(wf_id) + + components = workflow_def.get("components", {}) + + # 2. Register Components + # Expecting structure: + # components: + # simulation: + # type: function | script + # path: ... + # config: ... + + for name in ["simulation", "training", "active_learn", "criterion"]: + comp_def = components.get(name) + if not comp_def: + continue + + ctype = comp_def.get("type", "script") # Default to script? + cpath = comp_def.get("path") + + task_func = None + as_executable = True + + if ctype == "function": + task_func = cls._import_function(cpath) + as_executable = False + else: + # Script + task_func = cls._create_script_task_factory(cpath) + as_executable = True + + # Register using the appropriate decorator + if name == "simulation": + print(f"Registering simulation task: {task_func}") + learner.simulation_task(as_executable=as_executable)(task_func) + elif name == "training": + learner.training_task(as_executable=as_executable)(task_func) + elif name == "active_learn": + learner.active_learn_task(as_executable=as_executable)(task_func) + elif name == "criterion": + # Special handling for criterion + threshold = comp_def.get("threshold", 0.0) + metric = comp_def.get("metric", "CUSTOM") + learner.as_stop_criterion(metric_name=metric, threshold=threshold, as_executable=as_executable)(task_func) + + # 3. Build initial LearnerConfig from component defs + # This ensures that args/kwargs specified in YAML are used + l_config = LearnerConfig() + for name in ["simulation", "training", "active_learn", "criterion"]: + comp_def = components.get(name) + if comp_def and "config" in comp_def: + c_config = comp_def["config"] + t_config = TaskConfig( + args=tuple(c_config.get("args", ())), + kwargs=c_config.get("kwargs", {}) + ) + setattr(l_config, name, t_config) + + return learner, l_config + +class ServiceManager: + def __init__(self, job_id: str): + self.job_id = job_id + self.service_root = Path.home() / ".rose" / "services" / str(job_id) + self.requests_dir = self.service_root / "requests" + self.registry_file = self.service_root / "registry.json" + + self.workflows: Dict[str, Workflow] = {} + self.engine: Optional[WorkflowEngine] = None + self._learner_tasks: List[asyncio.Task] = [] + self._shutdown = False + + async def initialize(self): + """Setup directories and backend.""" + self.requests_dir.mkdir(parents=True, exist_ok=True) + + # Initialize AsyncFlow with Local Backend (or Slurm if needed, but 'service runs inside job') + # If running inside a job, we usually use Resource='local.localhost' or similar to spawn tasks + # on the allocated resources. + engine = await ConcurrentExecutionBackend(ProcessPoolExecutor()) + self.engine = await WorkflowEngine.create(engine) + logger.info(f"Service initialized at {self.service_root}") + + async def _process_requests(self): + """Pick up json files from requests_dir.""" + if not self.requests_dir.exists(): + return + + # Sort by mtime to process in order? + req_files = sorted(self.requests_dir.glob("*.json"), key=os.path.getmtime) + + for req_file in req_files: + try: + with open(req_file, "r") as f: + req = json.load(f) + + action = req.get("action") + payload = req.get("payload", {}) + + if action == "submit": + await self._handle_submit(req.get("id"), payload) + elif action == "cancel": + await self._handle_cancel(payload) + + # Remove request file after processing + req_file.unlink() + + except Exception as e: + logger.error(f"Error processing request {req_file}: {e}") + # Move to failed_requests? Or just delete? + # For now, delete to avoid loop + try: + req_file.unlink() + except: + pass + + async def _handle_submit(self, req_id: str, payload: Dict[str, Any]): + wf_file = payload.get("workflow_file") + if not wf_file: + logger.error("No workflow file in submit payload") + return + + # Use request ID as part of wf_id or generate new? + # User goal: "assigned a unique workflow identifier (wf_id)" + wf_id = f"wf.{req_id[:8]}" + + wf = Workflow(wf_id=wf_id, state=WorkflowState.INITIALIZING, workflow_file=wf_file) + self.workflows[wf_id] = wf + self._update_registry() + + try: + wf_def = WorkflowLoader.load_yaml(wf_file) + learner, initial_l_config = WorkflowLoader.create_learner(wf_id, wf_def, self.engine) + wf.learner_instance = learner + + # Merge with top-level config if needed (e.g. if we want to override via top-level) + # For now, initial_l_config from components is primary. + + # Start the learner loop as a background task + task = asyncio.create_task(self._run_learner(wf, wf_def.get("config", {}), initial_l_config)) + self._learner_tasks.append(task) + + except Exception as e: + logger.error(f"Failed to submit workflow {wf_id}: {e}") + wf.state = WorkflowState.FAILED + wf.error = str(e) + self._update_registry() + + async def _handle_cancel(self, payload: Dict[str, Any]): + wf_id = payload.get("wf_id") + wf = self.workflows.get(wf_id) + if wf and wf.state in [WorkflowState.RUNNING, WorkflowState.INITIALIZING, WorkflowState.SUBMITTED]: + logger.info(f"Canceling workflow {wf_id}") + if wf.learner_instance: + wf.learner_instance.stop() # Cooperative cancel + wf.state = WorkflowState.CANCELED + self._update_registry() + + async def _run_learner(self, wf: Workflow, config: Dict[str, Any], initial_l_config: Optional[LearnerConfig] = None): + """Driver loop for a single workflow.""" + wf.state = WorkflowState.RUNNING + wf.start_time = asyncio.get_event_loop().time() + self._update_registry() + + try: + max_iter = config.get("max_iterations", 0) + + async for state in wf.learner_instance.start(max_iter=max_iter, initial_config=initial_l_config): + # Update stats + wf.stats = state.to_dict() + # If we want to support granular status updates, update registry here + # (maybe throttled) + self._update_registry() + + wf.state = WorkflowState.COMPLETED + + except Exception as e: + logger.error(f"Workflow {wf.wf_id} failed: {e}") + wf.state = WorkflowState.FAILED + wf.error = str(e) + finally: + wf.end_time = asyncio.get_event_loop().time() + self._update_registry() + + def _update_registry(self): + """Dump registry to json.""" + data = {wf_id: wf.to_dict() for wf_id, wf in self.workflows.items()} + tmp_file = self.registry_file.with_suffix(".tmp") + with open(tmp_file, "w") as f: + json.dump(data, f, indent=2) + tmp_file.replace(self.registry_file) + + async def run(self): + """Main Service Loop.""" + await self.initialize() + logger.info("Service Manager Running") + + while not self._shutdown: + await self._process_requests() + await asyncio.sleep(1) # Polling interval + + async def shutdown(self): + self._shutdown = True + logger.info("Service Shutting Down") + + # 1. Stop all learners + for wf in self.workflows.values(): + if wf.learner_instance: + wf.learner_instance.stop() + + # 2. Cancel and wait for learner tasks + if self._learner_tasks: + for task in self._learner_tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*self._learner_tasks, return_exceptions=True) + self._learner_tasks.clear() + + # 3. Shutdown engine + if self.engine: + await self.engine.shutdown() + self.engine = None diff --git a/rose/service/models.py b/rose/service/models.py new file mode 100644 index 00000000..6d3a4ced --- /dev/null +++ b/rose/service/models.py @@ -0,0 +1,40 @@ +from enum import Enum, auto +from dataclasses import dataclass, field +from typing import Optional, Any, Dict +import time + +class WorkflowState(Enum): + """Lifecycle states for a ROSE Workflow/Learner.""" + SUBMITTED = "SUBMITTED" # Received but not yet started + INITIALIZING = "INITIALIZING" # Loading config and resources + RUNNING = "RUNNING" # Active execution + COMPLETED = "COMPLETED" # Finished successfully + FAILED = "FAILED" # Terminated with error + CANCELED = "CANCELED" # Stopped by user request + +@dataclass +class Workflow: + """Represents a managed workflow (learner) instance.""" + wf_id: str + state: WorkflowState = WorkflowState.SUBMITTED + workflow_file: str = "" + start_time: float = 0.0 + end_time: float = 0.0 + stats: Dict[str, Any] = field(default_factory=dict) + error: Optional[str] = None + + # Internal reference to the actual Learner object + # This is not serialized to JSON + learner_instance: Any = field(default=None, repr=False) + + def to_dict(self) -> Dict[str, Any]: + """Serializable representation for external monitoring.""" + return { + "wf_id": self.wf_id, + "state": self.state.value, + "workflow_file": self.workflow_file, + "start_time": self.start_time, + "end_time": self.end_time, + "stats": self.stats, + "error": self.error + } From 267d36640e46ef5a6e72afbe374cd8839a6a5013 Mon Sep 17 00:00:00 2001 From: AymenFJA Date: Fri, 30 Jan 2026 19:43:58 +0000 Subject: [PATCH 02/30] accomodate for other learners --- rose/service/manager.py | 83 ++++++++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 17 deletions(-) diff --git a/rose/service/manager.py b/rose/service/manager.py index 850e8d71..37ccfe3f 100644 --- a/rose/service/manager.py +++ b/rose/service/manager.py @@ -2,7 +2,7 @@ import json import os import shutil -import importlib.util +import importlib import sys import logging from pathlib import Path @@ -11,7 +11,7 @@ from radical.asyncflow import WorkflowEngine, ConcurrentExecutionBackend from concurrent.futures import ProcessPoolExecutor -from rose.al.active_learner import SequentialActiveLearner +from rose.al.active_learner import SequentialActiveLearner, ParallelActiveLearner from rose.learner import LearnerConfig, TaskConfig from .models import Workflow, WorkflowState @@ -75,17 +75,34 @@ async def task_func(*args, **kwargs): if v: cmd_parts.append(f"--{k}") else: cmd_parts.append(f"--{k} {v}") - - print(f"Task command: {' '.join(cmd_parts)}") + return " ".join(cmd_parts) return task_func @classmethod - def create_learner(cls, wf_id: str, workflow_def: Dict[str, Any], asyncflow: WorkflowEngine) -> SequentialActiveLearner: - """Create and configure a SequentialActiveLearner.""" + def create_learner(cls, wf_id: str, workflow_def: Dict[str, Any], asyncflow: WorkflowEngine): + """Create and configure a Learner based on the YAML definition.""" + + learner_def = workflow_def.get("learner", {}) + l_type = learner_def.get("type", "SequentialActiveLearner") + l_path = learner_def.get("path") + + # 1. Instantiate Learner Class + if l_path: + # Load custom learner class + try: + module_name, class_name = l_path.rsplit(".", 1) + module = importlib.import_module(module_name) + learner_cls = getattr(module, class_name) + except (ValueError, ImportError, AttributeError) as e: + raise ImportError(f"Could not import learner class '{l_path}': {e}") + elif l_type == "SequentialActiveLearner": + learner_cls = SequentialActiveLearner + else: + # Try to find it in rose.al or similar if we want to support more built-ins + raise ValueError(f"Unknown learner type '{l_type}' and no path provided.") - # 1. Create Learner - learner = SequentialActiveLearner(asyncflow) + learner = learner_cls(asyncflow) learner.learner_id = wf_id # Using wf_id (str) might need adaptation if learner_id expects int in some places? # rose/active_learner.py: learner_id (Optional[int]). # I should probably hash the wf_id or just set it if it accepts Any? @@ -120,17 +137,20 @@ def create_learner(cls, wf_id: str, workflow_def: Dict[str, Any], asyncflow: Wor # Script task_func = cls._create_script_task_factory(cpath) as_executable = True - + # Register using the appropriate decorator if name == "simulation": - print(f"Registering simulation task: {task_func}") + print(f"Registering simulation task for workflow {wf_id}") learner.simulation_task(as_executable=as_executable)(task_func) elif name == "training": + print(f"Registering training task for workflow {wf_id}") learner.training_task(as_executable=as_executable)(task_func) elif name == "active_learn": + print(f"Registering active_learn task for workflow {wf_id}") learner.active_learn_task(as_executable=as_executable)(task_func) elif name == "criterion": # Special handling for criterion + print(f"Registering criterion task for workflow {wf_id}") threshold = comp_def.get("threshold", 0.0) metric = comp_def.get("metric", "CUSTOM") learner.as_stop_criterion(metric_name=metric, threshold=threshold, as_executable=as_executable)(task_func) @@ -248,21 +268,50 @@ async def _handle_cancel(self, payload: Dict[str, Any]): wf.state = WorkflowState.CANCELED self._update_registry() - async def _run_learner(self, wf: Workflow, config: Dict[str, Any], initial_l_config: Optional[LearnerConfig] = None): + async def _run_learner(self, wf: Workflow, workflow_def: Dict[str, Any], initial_l_config: Optional[LearnerConfig] = None): """Driver loop for a single workflow.""" wf.state = WorkflowState.RUNNING wf.start_time = asyncio.get_event_loop().time() self._update_registry() try: - max_iter = config.get("max_iterations", 0) + learner_cfg = workflow_def.get("learner", {}) + max_iter = learner_cfg.get("max_iterations", workflow_def.get("max_iterations", 0)) - async for state in wf.learner_instance.start(max_iter=max_iter, initial_config=initial_l_config): - # Update stats - wf.stats = state.to_dict() - # If we want to support granular status updates, update registry here - # (maybe throttled) + # Identify learner type and call appropriately + if isinstance(wf.learner_instance, ParallelActiveLearner): + parallel_learners = learner_cfg.get("parallel_learners", 2) + + # ParallelActiveLearner.start doesn't take initial_config, + # but we can map it to learner_configs + l_configs = None + if initial_l_config: + l_configs = [initial_l_config] * parallel_learners + + results = await wf.learner_instance.start( + parallel_learners=parallel_learners, + max_iter=max_iter, + learner_configs=l_configs + ) + + # Update stats once at the end for parallel (since it's not yielding) + final_results = [] + for res in results: + if hasattr(res, "to_dict"): + final_results.append(res.to_dict()) + else: + final_results.append(str(res)) + + wf.stats = {"parallel_results": final_results} self._update_registry() + else: + # SequentialActiveLearner or other async iterator + async for state in wf.learner_instance.start( + max_iter=max_iter, + initial_config=initial_l_config + ): + wf.stats = state.to_dict() + self._update_registry() wf.state = WorkflowState.COMPLETED From c2d635cd4225b7dab1b58afac22472cc4bbab546 Mon Sep 17 00:00:00 2001 From: AymenFJA Date: Fri, 30 Jan 2026 21:33:32 +0000 Subject: [PATCH 03/30] adding loggers and shutdown mechanics --- rose/al/active_learner.py | 13 ++++--- rose/al/selector.py | 26 ++++++++----- rose/learner.py | 7 +++- rose/rl/reinforcement_learner.py | 31 +++++++++------- rose/service/api/cli.py | 37 +++++++++++++++---- rose/service/client.py | 14 ++++++- rose/service/manager.py | 63 ++++++++++++++++++++------------ rose/uq/uq_active_learner.py | 33 +++++++++-------- rose/uq/uq_learner.py | 7 +++- 9 files changed, 153 insertions(+), 78 deletions(-) diff --git a/rose/al/active_learner.py b/rose/al/active_learner.py index 5b874483..0aa6a2db 100644 --- a/rose/al/active_learner.py +++ b/rose/al/active_learner.py @@ -1,5 +1,6 @@ import asyncio import itertools +import logging import warnings from collections.abc import AsyncIterator, Coroutine, Iterator from typing import Any, Optional, Union @@ -8,6 +9,8 @@ from ..learner import IterationState, Learner, LearnerConfig +logger = logging.getLogger(__name__) + class SequentialActiveLearner(Learner): """Sequential active learner that runs iterations one after another. @@ -124,7 +127,7 @@ async def start( learner_suffix = ( f" (Learner-{self.learner_id})" if self.learner_id is not None else "" ) - print(f"Starting Active Learner{learner_suffix}") + logger.info(f"Starting Active Learner{learner_suffix}") # Initialize task references sim_task: Any = () @@ -168,7 +171,7 @@ async def start( f"[Learner-{self.learner_id}] " if self.learner_id is not None else "" ) if self.is_stopped: - print(f"{learner_prefix}Stop requested, exiting learning loop.") + logger.info(f"{learner_prefix}Stop requested, exiting learning loop.") break # Check for pending config @@ -186,7 +189,7 @@ async def start( if train_result is not None: self._extract_state_from_result(train_result) - print(f"{learner_prefix}Starting Iteration-{i}") + logger.info(f"{learner_prefix}Starting Iteration-{i}") # Get iteration-specific AL config acl_config = self._get_iteration_task_config( @@ -516,10 +519,10 @@ async def active_learner_workflow(learner_id: int) -> Any: return final_state except Exception as e: - print(f"ActiveLearner-{learner_id}] failed with error: {e}") + logger.error(f"ActiveLearner-{learner_id}] failed with error: {e}") raise - print(f"Starting Parallel Active Learning with {parallel_learners} learners") + logger.info(f"Starting Parallel Active Learning with {parallel_learners} learners") # Submit all learners asynchronously learners: list[Coroutine] = [ diff --git a/rose/al/selector.py b/rose/al/selector.py index 53a58817..ac7bc12e 100644 --- a/rose/al/selector.py +++ b/rose/al/selector.py @@ -1,5 +1,6 @@ import asyncio import itertools +import logging from collections.abc import Iterator from typing import Any, Callable, Optional, Union @@ -8,6 +9,8 @@ from ..learner import Learner, LearnerConfig, TaskConfig from .active_learner import SequentialActiveLearner +logger = logging.getLogger(__name__) + class AlgorithmSelector(Learner): """AlgorithmSelector runs multiple active learning algorithms in parallel. @@ -170,7 +173,7 @@ async def start( # Initialize algorithm configs if not provided algorithm_configs = algorithm_configs or {} - print( + logger.info( f"Starting algorithm selection with " f"{len(self.active_learn_functions)} algorithms: " f"{list(self.active_learn_functions.keys())}" @@ -207,7 +210,7 @@ async def _run_algorithm_pipeline( ) ) - print(f"[Algorithm-{algorithm_name}] Starting pipeline") + logger.info(f"[Algorithm-{algorithm_name}] Starting pipeline") # Track iterations and results for this algorithm iteration_count: int = 0 @@ -250,7 +253,7 @@ async def _run_algorithm_pipeline( # Main learning loop for i in iteration_range: - print(f"[Algorithm-{algorithm_name}] Starting Iteration-{i}") + logger.info(f"[Algorithm-{algorithm_name}] Starting Iteration-{i}") # Get iteration-specific configurations acl_config: TaskConfig = ( @@ -287,6 +290,11 @@ async def _run_algorithm_pipeline( iteration_count = i + 1 if should_stop: + logger.info( + f"[Algorithm-{algorithm_name}] Stop criterion met " + f"with value of: {final_result}. " + "Breaking the active learning loop." + ) break # Prepare next iteration tasks @@ -326,7 +334,7 @@ async def _run_algorithm_pipeline( } self.algorithm_results[algorithm_name] = result_dict - print( + logger.info( f"[Algorithm-{algorithm_name}] Completed " f" with {iteration_count} iterations, final result: {final_result}" ) @@ -334,7 +342,7 @@ async def _run_algorithm_pipeline( return self.algorithm_results[algorithm_name] except Exception as e: - print(f"[Algorithm-{algorithm_name}] Failed with error: {e}") + logger.error(f"[Algorithm-{algorithm_name}] Failed with error: {e}") # Store failure information error_dict: dict[str, Any] = { "iterations": 0, @@ -345,7 +353,7 @@ async def _run_algorithm_pipeline( raise # Submit all algorithm pipelines asynchronously - print(self.active_learn_functions) + logger.debug(self.active_learn_functions) futures: list[Any] = [ _run_algorithm_pipeline(al_name, al_task) for al_name, al_task in self.active_learn_functions.items() @@ -360,7 +368,7 @@ async def _run_algorithm_pipeline( zip(self.active_learn_functions.keys(), results) ): if isinstance(result, Exception): - print(f"[Algorithm-{algorithm_name}] Failed: {result}") + logger.error(f"[Algorithm-{algorithm_name}] Failed: {result}") self.algorithm_results[algorithm_name] = { "iterations": 0, "last_result": float("inf"), @@ -377,7 +385,7 @@ async def _run_algorithm_pipeline( } except Exception as e: - print(f"Error during algorithm selection: {e}") + logger.error(f"Error during algorithm selection: {e}") raise def _select_best_algorithm(self) -> None: @@ -415,7 +423,7 @@ def _select_best_algorithm(self) -> None: self.best_pipeline_name, self.best_pipeline_stats = sorted_algorithms[0] - print( + logger.info( f"Best algorithm is '{self.best_pipeline_name}' " f"with {self.best_pipeline_stats['iterations']} iteration(s) " f"and final metric result {self.best_pipeline_stats['last_result']}" diff --git a/rose/learner.py b/rose/learner.py index d0565a85..0b48b416 100644 --- a/rose/learner.py +++ b/rose/learner.py @@ -1,4 +1,5 @@ import asyncio +import logging from dataclasses import dataclass, field from functools import wraps from typing import Any, Callable, Optional, Union @@ -11,6 +12,8 @@ from .metrics import LearningMetrics as Metrics +logger = logging.getLogger(__name__) + @dataclass class IterationState: @@ -603,14 +606,14 @@ def _check_stop_criterion(self, stop_task_result: Any) -> tuple[bool, float]: self.iteration += 1 if self.compare_metric(metric_name, metric_value, threshold, operator): - print( + logger.info( f"stop criterion metric: {metric_name} " f"is met with value of: {metric_value} " ". Breaking the active learning loop" ) return True, metric_value else: - print( + logger.info( f"stop criterion metric: {metric_name} " f"is not met yet ({metric_value})." ) diff --git a/rose/rl/reinforcement_learner.py b/rose/rl/reinforcement_learner.py index 86a36855..50e6b20e 100644 --- a/rose/rl/reinforcement_learner.py +++ b/rose/rl/reinforcement_learner.py @@ -1,5 +1,6 @@ import asyncio import itertools +import logging import warnings from collections.abc import AsyncIterator, Coroutine, Iterator from functools import wraps @@ -10,6 +11,8 @@ from rose.learner import IterationState, Learner, LearnerConfig +logger = logging.getLogger(__name__) + class ReinforcementLearner(Learner): """Base class for reinforcement learning implementations. @@ -178,7 +181,7 @@ async def start( learner_suffix = ( f" (Learner-{self.learner_id})" if self.learner_id is not None else "" ) - print(f"Starting Sequential RL Learner{learner_suffix}") + logger.info(f"Starting Sequential RL Learner{learner_suffix}") # Initialize task references env_task: Any = () @@ -224,7 +227,7 @@ async def start( f"[Learner-{self.learner_id}] " if self.learner_id is not None else "" ) if self.is_stopped: - print(f"{learner_prefix}Stop requested, exiting learning loop.") + logger.info(f"{learner_prefix}Stop requested, exiting learning loop.") break # Check for pending config update @@ -245,7 +248,7 @@ async def start( learner_prefix = ( f"[Learner-{self.learner_id}] " if self.learner_id is not None else "" ) - print(f"{learner_prefix}Starting Iteration-{i}") + logger.info(f"{learner_prefix}Starting Iteration-{i}") # Check stop criterion if configured metric_value: Optional[float] = None @@ -512,10 +515,10 @@ def merge_banks(self) -> None: bank_files.append(os.path.join(self.work_dir, filename)) if not bank_files: - print("No experience banks found!") + logger.warning("No experience banks found!") return - print(f"Found {len(bank_files)} experience banks") + logger.info(f"Found {len(bank_files)} experience banks") # Create merged bank and load all files merged = ExperienceBank() @@ -526,20 +529,20 @@ def merge_banks(self) -> None: bank = ExperienceBank.load(bank_file) merged.merge_inplace(bank) total += len(bank) - print(f" Merged {len(bank)} from {os.path.basename(bank_file)}") + logger.info(f" Merged {len(bank)} from {os.path.basename(bank_file)}") except Exception as e: - print(f" Failed to load {bank_file}: {e}") + logger.error(f" Failed to load {bank_file}: {e}") # Clean up individual bank files for bank_file in bank_files: try: os.remove(bank_file) except Exception as e: - print(f" Failed to delete {bank_file}: {e}") + logger.error(f" Failed to delete {bank_file}: {e}") # Save merged bank merged.save(self.work_dir, "experience_bank.pkl") - print(f" Saved merged bank with {total} total experiences") + logger.info(f" Saved merged bank with {total} total experiences") async def start( self, @@ -584,7 +587,7 @@ async def start( learner_suffix = ( f" (Learner-{self.learner_id})" if self.learner_id is not None else "" ) - print(f"Starting Parallel Experience RL Learner{learner_suffix}") + logger.info(f"Starting Parallel Experience RL Learner{learner_suffix}") update_task: Any = () @@ -644,7 +647,7 @@ async def start( learner_prefix = ( f"[Learner-{self.learner_id}] " if self.learner_id is not None else "" ) - print(f"{learner_prefix}Starting Iteration-{i}") + logger.info(f"{learner_prefix}Starting Iteration-{i}") # Check stop criterion if configured metric_value: Optional[float] = None @@ -707,7 +710,7 @@ async def start( # Await update result (extract state in next iteration) update_result = await update_task - print(f"{learner_prefix}Finished Iteration-{i}") + logger.info(f"{learner_prefix}Finished Iteration-{i}") def set_next_config(self, config: LearnerConfig) -> None: """Set configuration for the next iteration. @@ -904,7 +907,7 @@ async def start( if len(learner_configs) != parallel_learners: raise ValueError("learner_configs length must match parallel_learners") - print( + logger.info( f"Starting Parallel Reinforcement Learning " f"with {parallel_learners} learners" ) @@ -957,7 +960,7 @@ async def rl_learner_workflow(learner_id: int) -> Any: return final_state except Exception as e: - print(f"RLLearner-{learner_id}] failed with error: {e}") + logger.error(f"RLLearner-{learner_id}] failed with error: {e}") raise # Submit all learners asynchronously diff --git a/rose/service/api/cli.py b/rose/service/api/cli.py index 7980dd39..5fe32d67 100644 --- a/rose/service/api/cli.py +++ b/rose/service/api/cli.py @@ -3,6 +3,7 @@ import os import sys import json +import logging from pathlib import Path from rose.service.manager import ServiceManager @@ -16,15 +17,20 @@ def get_job_id(): def cmd_launch(args): """Start the Service Manager.""" + # Configure logging for the service + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(name)s: %(message)s', + datefmt='%H:%M:%S' + ) + job_id = args.job_id or get_job_id() print(f"Launching ROSE Service for Job ID: {job_id}") manager = ServiceManager(job_id) - try: - asyncio.run(manager.run()) - except KeyboardInterrupt: - print("Service stopping...") - # graceful shutdown could be added here + # The loop in manager.run() handles signals if radical.asyncflow does, + # and the finally block ensures manager.shutdown() is called. + asyncio.run(manager.run()) def cmd_submit(args): """Submit a workflow.""" @@ -33,8 +39,10 @@ def cmd_submit(args): try: req_id = client.submit_workflow(args.workflow_file) - print(f"Submitted workflow request. Request ID: {req_id}") - print(f"Note: This is the request ID. The Workflow ID (wf_id) will be assigned by the service.") + wf_id = ServiceClient.get_wf_id(req_id) + print(f"Submitted workflow request.") + print(f"Request ID: {req_id}") + print(f"Workflow ID: {wf_id}") except Exception as e: print(f"Error submitting workflow: {e}") sys.exit(1) @@ -73,6 +81,17 @@ def cmd_status(args): print(f"Error getting status: {e}") sys.exit(1) +def cmd_shutdown(args): + """Shutdown the service.""" + job_id = args.job_id or get_job_id() + client = ServiceClient(job_id) + try: + client.shutdown() + print(f"Shutdown request sent to service (Job ID: {job_id})") + except Exception as e: + print(f"Error sending shutdown request: {e}") + sys.exit(1) + def main(): parser = argparse.ArgumentParser(description="ROSE Service CLI") subparsers = parser.add_subparsers(dest="command", required=True) @@ -100,6 +119,10 @@ def main(): p_status.add_argument("wf_id", nargs="?", help="Optional Workflow ID") p_status.set_defaults(func=cmd_status) + # Shutdown + p_shutdown = subparsers.add_parser("shutdown", parents=[parent_parser], help="Shutdown the service") + p_shutdown.set_defaults(func=cmd_shutdown) + args = parser.parse_args() args.func(args) diff --git a/rose/service/client.py b/rose/service/client.py index 3ce92b96..98913adc 100644 --- a/rose/service/client.py +++ b/rose/service/client.py @@ -1,9 +1,12 @@ import json import uuid import time +import logging from pathlib import Path from typing import Any, Dict, Optional, List +logger = logging.getLogger(__name__) + class ServiceClient: """Client for interacting with the ROSE Service via File-based IPC. @@ -12,6 +15,11 @@ class ServiceClient: service_root (Path): Root directory for service IPC (~/.rose/services/). """ + @staticmethod + def get_wf_id(req_id: str) -> str: + """Derive Workflow ID from Request ID.""" + return f"wf.{req_id[:8]}" + def __init__(self, job_id: str): self.job_id = job_id self.service_root = Path.home() / ".rose" / "services" / str(job_id) @@ -22,7 +30,7 @@ def __init__(self, job_id: str): # It's possible the service hasn't started creating dirs yet, # or the job ID is wrong. We don't raise immediately to allow # retry logic in scripts, but warn if needed. - print(f"Warning: Service root {self.service_root} does not exist yet.") + logger.warning(f"Service root {self.service_root} does not exist yet.") def _write_request(self, action: str, payload: Dict[str, Any]) -> str: """Write a request file to the requests directory.""" @@ -65,6 +73,10 @@ def cancel_workflow(self, wf_id: str) -> str: """ return self._write_request("cancel", {"wf_id": wf_id}) + def shutdown(self) -> str: + """Request graceful shutdown of the service.""" + return self._write_request("shutdown", {}) + def get_workflow_status(self, wf_id: str) -> Optional[Dict[str, Any]]: """Get the current status of a workflow from the registry. diff --git a/rose/service/manager.py b/rose/service/manager.py index 37ccfe3f..7ae555f0 100644 --- a/rose/service/manager.py +++ b/rose/service/manager.py @@ -14,6 +14,7 @@ from rose.al.active_learner import SequentialActiveLearner, ParallelActiveLearner from rose.learner import LearnerConfig, TaskConfig from .models import Workflow, WorkflowState +from .client import ServiceClient logger = logging.getLogger(__name__) @@ -140,17 +141,17 @@ def create_learner(cls, wf_id: str, workflow_def: Dict[str, Any], asyncflow: Wor # Register using the appropriate decorator if name == "simulation": - print(f"Registering simulation task for workflow {wf_id}") + logger.info(f"Registering simulation task for workflow {wf_id}") learner.simulation_task(as_executable=as_executable)(task_func) elif name == "training": - print(f"Registering training task for workflow {wf_id}") + logger.info(f"Registering training task for workflow {wf_id}") learner.training_task(as_executable=as_executable)(task_func) elif name == "active_learn": - print(f"Registering active_learn task for workflow {wf_id}") + logger.info(f"Registering active_learn task for workflow {wf_id}") learner.active_learn_task(as_executable=as_executable)(task_func) elif name == "criterion": # Special handling for criterion - print(f"Registering criterion task for workflow {wf_id}") + logger.info(f"Registering criterion task for workflow {wf_id}") threshold = comp_def.get("threshold", 0.0) metric = comp_def.get("metric", "CUSTOM") learner.as_stop_criterion(metric_name=metric, threshold=threshold, as_executable=as_executable)(task_func) @@ -185,12 +186,9 @@ def __init__(self, job_id: str): async def initialize(self): """Setup directories and backend.""" self.requests_dir.mkdir(parents=True, exist_ok=True) - - # Initialize AsyncFlow with Local Backend (or Slurm if needed, but 'service runs inside job') - # If running inside a job, we usually use Resource='local.localhost' or similar to spawn tasks - # on the allocated resources. - engine = await ConcurrentExecutionBackend(ProcessPoolExecutor()) - self.engine = await WorkflowEngine.create(engine) + + backend = await ConcurrentExecutionBackend(ProcessPoolExecutor()) + self.engine = await WorkflowEngine.create(backend) logger.info(f"Service initialized at {self.service_root}") async def _process_requests(self): @@ -213,6 +211,9 @@ async def _process_requests(self): await self._handle_submit(req.get("id"), payload) elif action == "cancel": await self._handle_cancel(payload) + elif action == "shutdown": + logger.info("Shutdown request received via IPC") + self._shutdown = True # Remove request file after processing req_file.unlink() @@ -234,7 +235,7 @@ async def _handle_submit(self, req_id: str, payload: Dict[str, Any]): # Use request ID as part of wf_id or generate new? # User goal: "assigned a unique workflow identifier (wf_id)" - wf_id = f"wf.{req_id[:8]}" + wf_id = ServiceClient.get_wf_id(req_id) wf = Workflow(wf_id=wf_id, state=WorkflowState.INITIALIZING, workflow_file=wf_file) self.workflows[wf_id] = wf @@ -272,6 +273,7 @@ async def _run_learner(self, wf: Workflow, workflow_def: Dict[str, Any], initial """Driver loop for a single workflow.""" wf.state = WorkflowState.RUNNING wf.start_time = asyncio.get_event_loop().time() + logger.info(f"Starting workflow {wf.wf_id} ({wf.workflow_file})") self._update_registry() try: @@ -293,6 +295,7 @@ async def _run_learner(self, wf: Workflow, workflow_def: Dict[str, Any], initial max_iter=max_iter, learner_configs=l_configs ) + logger.info(f"Workflow {wf.wf_id} - Parallel execution of {parallel_learners} learners finished") # Update stats once at the end for parallel (since it's not yielding) final_results = [] @@ -311,14 +314,17 @@ async def _run_learner(self, wf: Workflow, workflow_def: Dict[str, Any], initial initial_config=initial_l_config ): wf.stats = state.to_dict() + logger.info(f"Workflow {wf.wf_id} - Iteration {state.iteration} completed (metric: {state.metric_value})") self._update_registry() wf.state = WorkflowState.COMPLETED - + logger.info(f"Workflow {wf.wf_id} completed successfully") except Exception as e: - logger.error(f"Workflow {wf.wf_id} failed: {e}") wf.state = WorkflowState.FAILED wf.error = str(e) + logger.error(f"Workflow {wf.wf_id} failed: {e}") + import traceback + traceback.print_exc() finally: wf.end_time = asyncio.get_event_loop().time() self._update_registry() @@ -333,31 +339,42 @@ def _update_registry(self): async def run(self): """Main Service Loop.""" - await self.initialize() - logger.info("Service Manager Running") - - while not self._shutdown: - await self._process_requests() - await asyncio.sleep(1) # Polling interval + try: + await self.initialize() + logger.info("Service Manager Running") + + while not self._shutdown: + await self._process_requests() + await asyncio.sleep(0.1) # Polling interval + finally: + await self.shutdown() async def shutdown(self): self._shutdown = True - logger.info("Service Shutting Down") + logger.info("Service Shutting Down...") # 1. Stop all learners - for wf in self.workflows.values(): - if wf.learner_instance: - wf.learner_instance.stop() + if self.workflows: + logger.info(f"Stopping {len(self.workflows)} workflows") + for wf in self.workflows.values(): + if wf.learner_instance: + wf.learner_instance.stop() # 2. Cancel and wait for learner tasks if self._learner_tasks: + logger.info(f"Canceling {len(self._learner_tasks)} learner tasks") for task in self._learner_tasks: if not task.done(): task.cancel() await asyncio.gather(*self._learner_tasks, return_exceptions=True) self._learner_tasks.clear() + logger.info("All learner tasks stopped") # 3. Shutdown engine if self.engine: + logger.info("Shutting down workflow engine") await self.engine.shutdown() self.engine = None + logger.info("Workflow engine shut down") + + logger.info("Service shutdown complete") diff --git a/rose/uq/uq_active_learner.py b/rose/uq/uq_active_learner.py index 16dfe999..7bdbcbca 100644 --- a/rose/uq/uq_active_learner.py +++ b/rose/uq/uq_active_learner.py @@ -1,6 +1,7 @@ import asyncio import copy import itertools +import logging import warnings from collections.abc import AsyncIterator, Iterator from typing import Any, Optional, Union @@ -11,6 +12,8 @@ from ..learner import IterationState, TaskConfig +logger = logging.getLogger(__name__) + class SeqUQLearner(UQLearner): """UQ active learner that runs iterations one after another. @@ -101,7 +104,7 @@ async def start( # Initialize learner configs if not provided learning_config = learning_config or {} - print(f"[Learner {self.learner_name}] Starting execution...") + logger.info(f"[Learner {self.learner_name}] Starting execution...") if len(model_names) > 1: prefix = ( f"[Learner {self.learner_name}] starting training for " @@ -111,7 +114,7 @@ async def start( prefix = ( f"[Learner {self.learner_name}] starting training for Single Model: " ) - print(f"{prefix} {model_names}") + logger.info(f"{prefix} {model_names}") async def _training_stage( learning_config: TaskConfig, model_name: str, iteration_count: int @@ -168,14 +171,14 @@ async def _training_stage( ) prediction_tasks.append(prediction_task) - print( + logger.info( f"[{self.learner_name}-{model_name}] Completed training " f"for {iteration_count + 1} iteration(s) " ) return await asyncio.gather(*prediction_tasks) except Exception as e: - print( + logger.error( f"[{self.learner_name}-{model_name}] " f"Failed train/prediction with error: {e}" ) @@ -206,7 +209,7 @@ async def _training_stage( # Main learning loop for i in iteration_range: if self.is_stopped: - print( + logger.info( f"[Learner {self.learner_name}] Stop requested, " "exiting learning loop." ) @@ -215,7 +218,7 @@ async def _training_stage( # Clear transient state from previous iteration self.clear_state() - print(f"[Learner {self.learner_name}] Starting Iteration-{i}") + logger.info(f"[Learner {self.learner_name}] Starting Iteration-{i}") # Check uncertainty if configured uq_task: tuple = () @@ -229,13 +232,13 @@ async def _training_stage( uq_value = await uq_task if self.is_stopped: break - print(f"[Learner {self.learner_name}] {uq_value}") + logger.info(f"[Learner {self.learner_name}] {uq_value}") uq_model_stop, uq_stop_value = self._check_uncertainty(uq_value) self.register_state("uq_value", uq_stop_value) if uq_model_stop: - print( + logger.info( f"[Learner {self.learner_name}] UQ value reached " f"its threshold - Stopping training for all models" f" at iteration {i} with value: " @@ -264,7 +267,7 @@ async def _training_stage( break self._extract_state_from_result(al_results) - print(f"[Learner {self.learner_name}] {al_results}") + logger.info(f"[Learner {self.learner_name}] {al_results}") # Check stop criterion if configured metric_value: Optional[float] = None @@ -297,7 +300,7 @@ async def _training_stage( if model_stop: stop_training[model_name] = True should_stop_count += 1 - print( + logger.info( f"[Learner {self.learner_name}] Model " f"{model_name} will stop training" f" as stop criterion is met at iteration {i}" @@ -312,7 +315,7 @@ async def _training_stage( if should_stop_count == len(stops): should_stop = True - print( + logger.info( f"[Learner {self.learner_name}] Stopping " f"criterion met for all models at iteration {i} " f"with value: {stop_value}" @@ -343,7 +346,7 @@ async def _training_stage( if self.is_stopped: break - print( + logger.info( f"[Learner {self.learner_name}] Completed " f"{iteration_count + 1} iteration(s)" ) @@ -541,7 +544,7 @@ async def start( if len(learner_configs) != len(learner_names): raise ValueError("learner_configs length must match learner_names") - print( + logger.info( f"Starting Parallel UQ Active Learning with {len(learner_names)} learners" ) @@ -571,7 +574,7 @@ async def _run_sequential_learner(learner_name: str) -> Any: sequential_config: Optional[UQLearnerConfig] = ( self._convert_to_sequential_config(learner_configs[learner_name]) ) - print(f"[Parallel-Learner-{learner_name}] Starting sequential learning") + logger.info(f"[Parallel-Learner-{learner_name}] Starting sequential learning") # Run the sequential learner by iterating through start() final_state = None @@ -596,7 +599,7 @@ async def _run_sequential_learner(learner_name: str) -> Any: return final_state except Exception as e: - print(f"[Parallel-Learner-{learner_name}] Failed with error: {e}") + logger.error(f"[Parallel-Learner-{learner_name}] Failed with error: {e}") raise # Submit all learners asynchronously diff --git a/rose/uq/uq_learner.py b/rose/uq/uq_learner.py index 97c8e41e..3f01b531 100644 --- a/rose/uq/uq_learner.py +++ b/rose/uq/uq_learner.py @@ -1,3 +1,4 @@ +import logging from functools import wraps from typing import Any, Callable, Optional, Union @@ -6,6 +7,8 @@ from ..learner import Learner, LearnerConfig, TaskConfig +logger = logging.getLogger(__name__) + class UQLearnerConfig(LearnerConfig): """ @@ -148,14 +151,14 @@ def _check_uncertainty(self, uncertainty_task_result: Any) -> tuple[bool, float] if self.compare_metric( uq_metric_name, uncertainty_value, threshold, operator ): - print( + logger.info( f"Stop uncertainty metric: {uq_metric_name} " f"is met with value of: {uncertainty_value} " ". Breaking the active learning loop" ) return True, uncertainty_value else: - print( + logger.info( f"Uncertainty metric: {uq_metric_name} " f"is not met yet ({uncertainty_value})." ) From 0acd1ca93f7f542131652101898003b52405f367 Mon Sep 17 00:00:00 2001 From: AymenFJA Date: Fri, 30 Jan 2026 22:44:00 +0000 Subject: [PATCH 04/30] fix parallel_learners not considered --- rose/service/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rose/service/manager.py b/rose/service/manager.py index 7ae555f0..df859481 100644 --- a/rose/service/manager.py +++ b/rose/service/manager.py @@ -282,7 +282,7 @@ async def _run_learner(self, wf: Workflow, workflow_def: Dict[str, Any], initial # Identify learner type and call appropriately if isinstance(wf.learner_instance, ParallelActiveLearner): - parallel_learners = learner_cfg.get("parallel_learners", 2) + parallel_learners = learner_cfg.get("parallel_learners", workflow_def.get("parallel_learners", 2)) # ParallelActiveLearner.start doesn't take initial_config, # but we can map it to learner_configs From 07e80c066db654dcdd50344fb17356cbed773357 Mon Sep 17 00:00:00 2001 From: Aymen Alsaadi <27039262+AymenFJA@users.noreply.github.com> Date: Sun, 8 Feb 2026 13:40:18 -0500 Subject: [PATCH 05/30] cleanup --- ROSEE_IMPLEMENTATION_PLAN.md | 1427 ---------------------------------- 1 file changed, 1427 deletions(-) delete mode 100644 ROSEE_IMPLEMENTATION_PLAN.md diff --git a/ROSEE_IMPLEMENTATION_PLAN.md b/ROSEE_IMPLEMENTATION_PLAN.md deleted file mode 100644 index 2c43dbc2..00000000 --- a/ROSEE_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,1427 +0,0 @@ -# ROSEE Implementation Plan - -## Overview - -ROSEE is an agent-enablement layer on top of ROSE that: -1. Toolifies ROSE capabilities (Python functions + MCP protocol) -2. Provides standardized workflow state for agent consumption -3. Enables per-iteration agent steering with timeout/default fallback - ---- - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ User's Decision Function │ -│ decision_fn(state: WorkflowState) -> Action │ -│ (LLM with tools, RL policy, bandit, or custom logic) │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ ActiveLearningAgent │ -│ • Wraps ROSE learner │ -│ • Observe → Decide → Apply → Execute loop │ -│ • Timeout with default fallback │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ ROSEE Tools │ -│ • Python functions + MCP protocol │ -│ • select_samples, set_hyperparams, get_uncertainty, stop │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ ROSE Layer │ -│ SequentialActiveLearner, ParallelActiveLearner, etc. │ -│ HPC execution via RADICAL-AsyncFlow │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Module Structure - -``` -rosee/ -├── __init__.py -├── agents/ -│ ├── __init__.py -│ ├── base.py # BaseAgent with observe/apply/execute -│ ├── active_learning.py # ActiveLearningAgent -│ └── reinforcement.py # ReinforcementLearningAgent -├── state/ -│ ├── __init__.py -│ ├── workflow_state.py # WorkflowState dataclass -│ └── actions.py # Action, ActionType definitions -├── tools/ -│ ├── __init__.py -│ ├── base.py # BaseTool class -│ ├── sample_selection.py # SelectSamplesTool -│ ├── hyperparameters.py # SetHyperparametersTool -│ ├── uncertainty.py # GetUncertaintyTool -│ ├── control.py # StopTool, ContinueTool -│ └── registry.py # ToolRegistry for discovery -├── mcp/ -│ ├── __init__.py -│ ├── server.py # MCP server exposing tools -│ └── schemas.py # MCP tool schemas -├── defaults/ -│ ├── __init__.py -│ └── policies.py # Default fallback policies -└── utils/ - ├── __init__.py - └── timeout.py # Timeout utilities -``` - ---- - -## Phase 1: Core State and Actions - -### 1.1 WorkflowState (`rosee/state/workflow_state.py`) - -```python -from dataclasses import dataclass, field -from typing import Dict, List, Optional -import numpy as np - - -@dataclass -class WorkflowState: - """Standardized state for agent consumption.""" - - # Identity - workflow_id: str - learner_type: str # 'sequential', 'parallel', 'algorithm_selector' - - # Progress - iteration: int - max_iterations: Optional[int] - - # Primary metric - metric_name: str - metric_value: float - metric_threshold: Optional[float] - metric_history: List[float] = field(default_factory=list) - - # Data state - labeled_count: int = 0 - unlabeled_count: int = 0 - samples_per_iteration: List[int] = field(default_factory=list) - - # Uncertainty (if available) - uncertainty_scores: Optional[np.ndarray] = None - mean_uncertainty: Optional[float] = None - - # Current configuration - current_config: Dict = field(default_factory=dict) - - # Resource usage - compute_used: float = 0.0 # Normalized units - elapsed_seconds: float = 0.0 - - def to_dict(self) -> Dict: - """Convert to dictionary for serialization.""" - return { - 'workflow_id': self.workflow_id, - 'learner_type': self.learner_type, - 'iteration': self.iteration, - 'max_iterations': self.max_iterations, - 'metric_name': self.metric_name, - 'metric_value': self.metric_value, - 'metric_threshold': self.metric_threshold, - 'metric_history': self.metric_history, - 'labeled_count': self.labeled_count, - 'unlabeled_count': self.unlabeled_count, - 'mean_uncertainty': self.mean_uncertainty, - 'current_config': self.current_config, - 'compute_used': self.compute_used, - 'elapsed_seconds': self.elapsed_seconds, - } - - def to_prompt(self) -> str: - """Convert to natural language for LLM agents.""" - lines = [ - f"## Workflow State (Iteration {self.iteration})", - f"", - f"**Progress:**", - f"- Iteration: {self.iteration}" + (f" / {self.max_iterations}" if self.max_iterations else ""), - f"- Metric ({self.metric_name}): {self.metric_value:.6f}" + (f" (target: {self.metric_threshold})" if self.metric_threshold else ""), - f"- Metric history: {[f'{v:.4f}' for v in self.metric_history[-5:]]}", - f"", - f"**Data:**", - f"- Labeled samples: {self.labeled_count}", - f"- Unlabeled samples: {self.unlabeled_count}", - ] - - if self.mean_uncertainty is not None: - lines.append(f"- Mean uncertainty: {self.mean_uncertainty:.4f}") - - lines.extend([ - f"", - f"**Resources:**", - f"- Compute used: {self.compute_used:.2f} units", - f"- Elapsed time: {self.elapsed_seconds:.1f}s", - ]) - - if self.current_config: - lines.extend([ - f"", - f"**Current config:**", - ]) - for k, v in self.current_config.items(): - lines.append(f"- {k}: {v}") - - return "\n".join(lines) - - def to_vector(self) -> np.ndarray: - """Convert to numeric vector for RL agents.""" - # Normalize and concatenate numeric features - features = [ - self.iteration / (self.max_iterations or 100), - self.metric_value, - self.labeled_count / max(self.labeled_count + self.unlabeled_count, 1), - self.mean_uncertainty or 0.0, - self.compute_used / 1000, # Normalize - ] - # Add recent metric history (padded) - history = self.metric_history[-5:] + [0.0] * (5 - len(self.metric_history[-5:])) - features.extend(history) - - return np.array(features, dtype=np.float32) -``` - -### 1.2 Actions (`rosee/state/actions.py`) - -```python -from dataclasses import dataclass, field -from enum import Enum -from typing import Any, Dict, List, Optional - - -class ActionType(Enum): - """Types of actions an agent can take.""" - SELECT_SAMPLES = "select_samples" - SET_HYPERPARAMETERS = "set_hyperparameters" - GET_UNCERTAINTY = "get_uncertainty" - CONTINUE = "continue" - STOP = "stop" - - -@dataclass -class Action: - """Action to be applied to workflow.""" - - action_type: ActionType - parameters: Dict[str, Any] = field(default_factory=dict) - - @classmethod - def select_samples( - cls, - strategy: str = "uncertainty", - count: int = 10, - indices: Optional[List[int]] = None - ) -> "Action": - """Create sample selection action.""" - return cls( - action_type=ActionType.SELECT_SAMPLES, - parameters={ - "strategy": strategy, - "count": count, - "indices": indices - } - ) - - @classmethod - def set_hyperparameters(cls, **kwargs) -> "Action": - """Create hyperparameter setting action.""" - return cls( - action_type=ActionType.SET_HYPERPARAMETERS, - parameters=kwargs - ) - - @classmethod - def get_uncertainty(cls, metric: str = "predictive_entropy") -> "Action": - """Create uncertainty query action.""" - return cls( - action_type=ActionType.GET_UNCERTAINTY, - parameters={"metric": metric} - ) - - @classmethod - def continue_iteration(cls) -> "Action": - """Create continue action (use defaults).""" - return cls(action_type=ActionType.CONTINUE) - - @classmethod - def stop(cls, reason: str = "agent_decision") -> "Action": - """Create stop action.""" - return cls( - action_type=ActionType.STOP, - parameters={"reason": reason} - ) - - -@dataclass -class ActionResult: - """Result of applying an action.""" - - success: bool - action_type: ActionType - data: Dict[str, Any] = field(default_factory=dict) - error: Optional[str] = None -``` - ---- - -## Phase 2: Tools (Python Functions) - -### 2.1 Base Tool (`rosee/tools/base.py`) - -```python -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import Any, Callable, Dict, Optional - -from ..state.actions import Action, ActionResult -from ..state.workflow_state import WorkflowState - - -@dataclass -class ToolSpec: - """Specification for a tool (for LLM function calling).""" - - name: str - description: str - parameters: Dict[str, Any] # JSON Schema format - required: list[str] - - -class BaseTool(ABC): - """Base class for ROSEE tools.""" - - name: str - description: str - - @abstractmethod - def get_spec(self) -> ToolSpec: - """Get tool specification for LLM.""" - pass - - @abstractmethod - def execute(self, state: WorkflowState, **kwargs) -> ActionResult: - """Execute tool and return result.""" - pass - - def to_action(self, **kwargs) -> Action: - """Convert tool call to Action.""" - pass -``` - -### 2.2 Sample Selection Tool (`rosee/tools/sample_selection.py`) - -```python -from typing import List, Optional - -from .base import BaseTool, ToolSpec -from ..state.actions import Action, ActionResult, ActionType -from ..state.workflow_state import WorkflowState - - -class SelectSamplesTool(BaseTool): - """Tool for selecting samples in active learning.""" - - name = "select_samples" - description = """Select samples from the unlabeled pool for the next iteration. - - Strategies: - - 'uncertainty': Select most uncertain samples (exploitation) - - 'diversity': Select most diverse samples (exploration) - - 'random': Random selection (baseline) - - 'hybrid': Balance uncertainty and diversity - - You can also provide explicit indices if you have specific samples in mind.""" - - def get_spec(self) -> ToolSpec: - return ToolSpec( - name=self.name, - description=self.description, - parameters={ - "type": "object", - "properties": { - "strategy": { - "type": "string", - "enum": ["uncertainty", "diversity", "random", "hybrid"], - "description": "Sample selection strategy" - }, - "count": { - "type": "integer", - "description": "Number of samples to select", - "minimum": 1, - "maximum": 1000 - }, - "indices": { - "type": "array", - "items": {"type": "integer"}, - "description": "Explicit indices to select (overrides strategy)" - } - } - }, - required=["strategy", "count"] - ) - - def execute( - self, - state: WorkflowState, - strategy: str = "uncertainty", - count: int = 10, - indices: Optional[List[int]] = None - ) -> ActionResult: - """Execute sample selection.""" - # Validation - if indices is not None: - if any(i >= state.unlabeled_count for i in indices): - return ActionResult( - success=False, - action_type=ActionType.SELECT_SAMPLES, - error=f"Invalid indices: max index is {state.unlabeled_count - 1}" - ) - selected = indices - else: - if count > state.unlabeled_count: - count = state.unlabeled_count - selected = None # Will be computed during apply - - return ActionResult( - success=True, - action_type=ActionType.SELECT_SAMPLES, - data={ - "strategy": strategy, - "count": count, - "indices": selected - } - ) - - def to_action( - self, - strategy: str = "uncertainty", - count: int = 10, - indices: Optional[List[int]] = None - ) -> Action: - return Action.select_samples(strategy=strategy, count=count, indices=indices) -``` - -### 2.3 Hyperparameters Tool (`rosee/tools/hyperparameters.py`) - -```python -from typing import Any, Dict, Optional - -from .base import BaseTool, ToolSpec -from ..state.actions import Action, ActionResult, ActionType -from ..state.workflow_state import WorkflowState - - -class SetHyperparametersTool(BaseTool): - """Tool for setting training hyperparameters.""" - - name = "set_hyperparameters" - description = """Set hyperparameters for the next training iteration. - - Common parameters: - - learning_rate: Learning rate for optimizer (e.g., 0.001) - - batch_size: Training batch size (e.g., 32, 64, 128) - - epochs: Number of training epochs (e.g., 10, 50, 100) - - Only set parameters you want to change; others keep their current values.""" - - def get_spec(self) -> ToolSpec: - return ToolSpec( - name=self.name, - description=self.description, - parameters={ - "type": "object", - "properties": { - "learning_rate": { - "type": "number", - "description": "Learning rate", - "minimum": 1e-7, - "maximum": 1.0 - }, - "batch_size": { - "type": "integer", - "description": "Batch size", - "minimum": 1, - "maximum": 4096 - }, - "epochs": { - "type": "integer", - "description": "Number of epochs", - "minimum": 1, - "maximum": 1000 - } - }, - "additionalProperties": True # Allow custom params - }, - required=[] - ) - - def execute( - self, - state: WorkflowState, - **kwargs - ) -> ActionResult: - """Execute hyperparameter setting.""" - return ActionResult( - success=True, - action_type=ActionType.SET_HYPERPARAMETERS, - data=kwargs - ) - - def to_action(self, **kwargs) -> Action: - return Action.set_hyperparameters(**kwargs) -``` - -### 2.4 Uncertainty Tool (`rosee/tools/uncertainty.py`) - -```python -from typing import Dict - -from .base import BaseTool, ToolSpec -from ..state.actions import Action, ActionResult, ActionType -from ..state.workflow_state import WorkflowState - - -class GetUncertaintyTool(BaseTool): - """Tool for querying uncertainty metrics.""" - - name = "get_uncertainty" - description = """Get uncertainty information about the current model and unlabeled pool. - - Available metrics: - - 'predictive_entropy': Entropy of predicted probabilities - - 'mutual_information': Information between predictions and model params - - 'predictive_variance': Variance of predictions (regression) - - 'margin': Difference between top two class probabilities - - Returns statistics about uncertainty distribution.""" - - def get_spec(self) -> ToolSpec: - return ToolSpec( - name=self.name, - description=self.description, - parameters={ - "type": "object", - "properties": { - "metric": { - "type": "string", - "enum": [ - "predictive_entropy", - "mutual_information", - "predictive_variance", - "margin" - ], - "description": "Uncertainty metric to compute" - } - } - }, - required=["metric"] - ) - - def execute( - self, - state: WorkflowState, - metric: str = "predictive_entropy" - ) -> ActionResult: - """Get uncertainty statistics from state.""" - if state.uncertainty_scores is None: - return ActionResult( - success=False, - action_type=ActionType.GET_UNCERTAINTY, - error="No uncertainty scores available" - ) - - import numpy as np - scores = state.uncertainty_scores - - return ActionResult( - success=True, - action_type=ActionType.GET_UNCERTAINTY, - data={ - "metric": metric, - "mean": float(np.mean(scores)), - "std": float(np.std(scores)), - "min": float(np.min(scores)), - "max": float(np.max(scores)), - "percentiles": { - "25": float(np.percentile(scores, 25)), - "50": float(np.percentile(scores, 50)), - "75": float(np.percentile(scores, 75)), - "90": float(np.percentile(scores, 90)) - } - } - ) - - def to_action(self, metric: str = "predictive_entropy") -> Action: - return Action.get_uncertainty(metric=metric) -``` - -### 2.5 Control Tools (`rosee/tools/control.py`) - -```python -from .base import BaseTool, ToolSpec -from ..state.actions import Action, ActionResult, ActionType -from ..state.workflow_state import WorkflowState - - -class StopTool(BaseTool): - """Tool to stop the workflow.""" - - name = "stop" - description = """Stop the active learning workflow. - - Use when: - - Target metric has been reached - - Metric is no longer improving (converged) - - Budget (compute/samples) is exhausted - - Further training would not be beneficial""" - - def get_spec(self) -> ToolSpec: - return ToolSpec( - name=self.name, - description=self.description, - parameters={ - "type": "object", - "properties": { - "reason": { - "type": "string", - "description": "Reason for stopping" - } - } - }, - required=["reason"] - ) - - def execute(self, state: WorkflowState, reason: str = "") -> ActionResult: - return ActionResult( - success=True, - action_type=ActionType.STOP, - data={"reason": reason} - ) - - def to_action(self, reason: str = "agent_decision") -> Action: - return Action.stop(reason=reason) - - -class ContinueTool(BaseTool): - """Tool to continue with default settings.""" - - name = "continue" - description = """Continue to the next iteration with default settings. - - Use when current configuration is working well and no changes needed.""" - - def get_spec(self) -> ToolSpec: - return ToolSpec( - name=self.name, - description=self.description, - parameters={"type": "object", "properties": {}}, - required=[] - ) - - def execute(self, state: WorkflowState) -> ActionResult: - return ActionResult( - success=True, - action_type=ActionType.CONTINUE, - data={} - ) - - def to_action(self) -> Action: - return Action.continue_iteration() -``` - -### 2.6 Tool Registry (`rosee/tools/registry.py`) - -```python -from typing import Dict, List - -from .base import BaseTool, ToolSpec -from .sample_selection import SelectSamplesTool -from .hyperparameters import SetHyperparametersTool -from .uncertainty import GetUncertaintyTool -from .control import StopTool, ContinueTool - - -class ToolRegistry: - """Registry of available ROSEE tools.""" - - def __init__(self): - self._tools: Dict[str, BaseTool] = {} - self._register_default_tools() - - def _register_default_tools(self): - """Register default ROSEE tools.""" - default_tools = [ - SelectSamplesTool(), - SetHyperparametersTool(), - GetUncertaintyTool(), - StopTool(), - ContinueTool() - ] - for tool in default_tools: - self.register(tool) - - def register(self, tool: BaseTool): - """Register a tool.""" - self._tools[tool.name] = tool - - def get(self, name: str) -> BaseTool: - """Get a tool by name.""" - return self._tools.get(name) - - def list_tools(self) -> List[BaseTool]: - """List all registered tools.""" - return list(self._tools.values()) - - def get_specs(self) -> List[ToolSpec]: - """Get all tool specifications (for LLM).""" - return [tool.get_spec() for tool in self._tools.values()] - - def to_openai_format(self) -> List[Dict]: - """Convert to OpenAI function calling format.""" - return [ - { - "type": "function", - "function": { - "name": spec.name, - "description": spec.description, - "parameters": spec.parameters - } - } - for spec in self.get_specs() - ] - - def to_anthropic_format(self) -> List[Dict]: - """Convert to Anthropic tool use format.""" - return [ - { - "name": spec.name, - "description": spec.description, - "input_schema": spec.parameters - } - for spec in self.get_specs() - ] -``` - ---- - -## Phase 3: Active Learning Agent - -### 3.1 Base Agent (`rosee/agents/base.py`) - -```python -import asyncio -from abc import ABC, abstractmethod -from typing import Any, Callable, Optional - -from ..state.workflow_state import WorkflowState -from ..state.actions import Action, ActionType -from ..tools.registry import ToolRegistry -from ..defaults.policies import DefaultPolicy - - -class BaseAgent(ABC): - """Base class for ROSEE agents.""" - - def __init__( - self, - learner: Any, # ROSE learner - decision_fn: Callable[[WorkflowState], Action], - decision_timeout: float = 30.0, - default_policy: Optional[DefaultPolicy] = None - ): - self.learner = learner - self.decide = decision_fn - self.decision_timeout = decision_timeout - self.default_policy = default_policy or DefaultPolicy() - self.tools = ToolRegistry() - - # State tracking - self._iteration = 0 - self._metric_history = [] - self._start_time = None - - @abstractmethod - def _observe(self) -> WorkflowState: - """Gather current state from learner.""" - pass - - @abstractmethod - def _apply(self, action: Action) -> None: - """Apply action to configure learner.""" - pass - - @abstractmethod - async def _execute_iteration(self) -> Any: - """Execute one iteration via ROSE.""" - pass - - async def _decide_with_timeout(self, state: WorkflowState) -> Action: - """Call decision function with timeout and fallback.""" - try: - # Handle both sync and async decision functions - if asyncio.iscoroutinefunction(self.decide): - action = await asyncio.wait_for( - self.decide(state), - timeout=self.decision_timeout - ) - else: - action = await asyncio.wait_for( - asyncio.get_event_loop().run_in_executor( - None, self.decide, state - ), - timeout=self.decision_timeout - ) - return action - except asyncio.TimeoutError: - print(f"Decision timeout ({self.decision_timeout}s), using default policy") - return self.default_policy.decide(state) - except Exception as e: - print(f"Decision error: {e}, using default policy") - return self.default_policy.decide(state) - - @abstractmethod - async def run(self, max_iter: int) -> Any: - """Run the agent-controlled workflow.""" - pass -``` - -### 3.2 Active Learning Agent (`rosee/agents/active_learning.py`) - -```python -import time -from typing import Any, Callable, Optional - -from rose.al import SequentialActiveLearner -from rose.learner import LearnerConfig, TaskConfig - -from .base import BaseAgent -from ..state.workflow_state import WorkflowState -from ..state.actions import Action, ActionType -from ..defaults.policies import DefaultPolicy - - -class ActiveLearningAgent(BaseAgent): - """Agent that controls a ROSE SequentialActiveLearner.""" - - def __init__( - self, - learner: SequentialActiveLearner, - decision_fn: Callable[[WorkflowState], Action], - decision_timeout: float = 30.0, - default_policy: Optional[DefaultPolicy] = None - ): - super().__init__(learner, decision_fn, decision_timeout, default_policy) - - # AL-specific state - self._current_config = LearnerConfig() - self._labeled_count = 0 - self._unlabeled_count = 0 - self._uncertainty_scores = None - self._pending_action: Optional[Action] = None - - def _observe(self) -> WorkflowState: - """Gather current state from the ROSE learner.""" - # Get metric info from learner - metric_history = list(self.learner.metric_values_per_iteration.values()) - current_metric = metric_history[-1] if metric_history else float('inf') - - # Get criterion info if available - metric_name = "unknown" - threshold = None - if self.learner.criterion_function: - metric_name = self.learner.criterion_function.get("metric_name", "unknown") - threshold = self.learner.criterion_function.get("threshold") - - # Build state - state = WorkflowState( - workflow_id=str(id(self.learner)), - learner_type="sequential", - iteration=self._iteration, - max_iterations=None, # Set during run() - metric_name=metric_name, - metric_value=current_metric, - metric_threshold=threshold, - metric_history=metric_history, - labeled_count=self._labeled_count, - unlabeled_count=self._unlabeled_count, - uncertainty_scores=self._uncertainty_scores, - mean_uncertainty=( - float(self._uncertainty_scores.mean()) - if self._uncertainty_scores is not None - else None - ), - current_config=self._config_to_dict(), - compute_used=0.0, # TODO: Track from RADICAL - elapsed_seconds=time.time() - self._start_time if self._start_time else 0.0 - ) - - return state - - def _config_to_dict(self) -> dict: - """Convert current LearnerConfig to dict.""" - config = {} - if self._current_config.training: - if isinstance(self._current_config.training, TaskConfig): - config.update(self._current_config.training.kwargs) - return config - - def _apply(self, action: Action) -> None: - """Apply action to configure the learner for next iteration.""" - if action.action_type == ActionType.SELECT_SAMPLES: - # Store selection params for next iteration - self._pending_action = action - - elif action.action_type == ActionType.SET_HYPERPARAMETERS: - # Update training config - params = action.parameters - current_kwargs = {} - if self._current_config.training: - if isinstance(self._current_config.training, TaskConfig): - current_kwargs = self._current_config.training.kwargs.copy() - - # Map common names to CLI args - if "learning_rate" in params: - current_kwargs["--lr"] = str(params["learning_rate"]) - if "batch_size" in params: - current_kwargs["--batch_size"] = str(params["batch_size"]) - if "epochs" in params: - current_kwargs["--epochs"] = str(params["epochs"]) - - # Add any additional params - for k, v in params.items(): - if k not in ["learning_rate", "batch_size", "epochs"]: - current_kwargs[f"--{k}"] = str(v) - - self._current_config.training = TaskConfig(kwargs=current_kwargs) - - elif action.action_type == ActionType.CONTINUE: - # Keep current config - pass - - elif action.action_type == ActionType.STOP: - # Handled in run loop - pass - - async def _execute_iteration(self) -> Any: - """Execute one AL iteration via ROSE learner.""" - # Build iteration-specific config - iter_config = LearnerConfig( - simulation=self._current_config.simulation, - training=self._current_config.training, - active_learn=self._current_config.active_learn, - criterion=self._current_config.criterion - ) - - # If we have a pending sample selection action, encode it - if self._pending_action and self._pending_action.action_type == ActionType.SELECT_SAMPLES: - params = self._pending_action.parameters - al_kwargs = { - "--strategy": params.get("strategy", "uncertainty"), - "--count": str(params.get("count", 10)) - } - if params.get("indices"): - al_kwargs["--indices"] = ",".join(map(str, params["indices"])) - - iter_config.active_learn = TaskConfig(kwargs=al_kwargs) - self._pending_action = None - - # Execute via ROSE - # Note: This is simplified - actual implementation needs to handle - # the internal teach() loop structure - result = await self._run_single_iteration(iter_config) - - self._iteration += 1 - return result - - async def _run_single_iteration(self, config: LearnerConfig) -> Any: - """Run a single iteration with given config.""" - # This hooks into ROSE's internal iteration mechanism - # Implementation depends on how we modify ROSE or wrap it - - # For now, use the teach() with max_iter=1 - # In practice, we'd need finer control or a modified ROSE API - pass - - async def run(self, max_iter: int) -> dict: - """Run the agent-controlled active learning workflow.""" - self._start_time = time.time() - results = { - "iterations": 0, - "final_metric": None, - "metric_history": [], - "stop_reason": None - } - - for i in range(max_iter): - self._iteration = i - - # 1. Observe current state - state = self._observe() - state.max_iterations = max_iter - - # 2. Agent makes decision (with timeout) - action = await self._decide_with_timeout(state) - - # 3. Check for stop action - if action.action_type == ActionType.STOP: - results["stop_reason"] = action.parameters.get("reason", "agent_decision") - break - - # 4. Apply action to configure learner - self._apply(action) - - # 5. Execute iteration via ROSE - iter_result = await self._execute_iteration() - - # 6. Update results - results["iterations"] = i + 1 - results["metric_history"] = list( - self.learner.metric_values_per_iteration.values() - ) - if results["metric_history"]: - results["final_metric"] = results["metric_history"][-1] - - if results["stop_reason"] is None: - results["stop_reason"] = "max_iterations" - - return results -``` - ---- - -## Phase 4: Default Policies - -### 4.1 Default Policy (`rosee/defaults/policies.py`) - -```python -from typing import Optional -import numpy as np - -from ..state.workflow_state import WorkflowState -from ..state.actions import Action - - -class DefaultPolicy: - """Default policy used when agent times out or errors.""" - - def __init__( - self, - default_strategy: str = "uncertainty", - default_sample_count: int = 10, - convergence_threshold: float = 0.001, - patience: int = 5 - ): - self.default_strategy = default_strategy - self.default_sample_count = default_sample_count - self.convergence_threshold = convergence_threshold - self.patience = patience - - def decide(self, state: WorkflowState) -> Action: - """Make a default decision based on state.""" - - # Check for convergence (no improvement for `patience` iterations) - if len(state.metric_history) >= self.patience: - recent = state.metric_history[-self.patience:] - improvement = abs(recent[0] - recent[-1]) - if improvement < self.convergence_threshold: - return Action.stop(reason="converged (default policy)") - - # Check if threshold reached - if state.metric_threshold is not None: - if state.metric_value <= state.metric_threshold: - return Action.stop(reason="threshold reached (default policy)") - - # Default: continue with uncertainty sampling - return Action.select_samples( - strategy=self.default_strategy, - count=self.default_sample_count - ) - - -class AdaptiveDefaultPolicy(DefaultPolicy): - """Adaptive default policy that adjusts based on progress.""" - - def decide(self, state: WorkflowState) -> Action: - """Make adaptive default decision.""" - - # First check stop conditions - base_action = super().decide(state) - if base_action.action_type.value == "stop": - return base_action - - # Adapt sample count based on uncertainty - if state.mean_uncertainty is not None: - # High uncertainty -> more samples - if state.mean_uncertainty > 0.7: - count = min(self.default_sample_count * 2, state.unlabeled_count) - elif state.mean_uncertainty < 0.3: - count = max(self.default_sample_count // 2, 1) - else: - count = self.default_sample_count - else: - count = self.default_sample_count - - # Adapt strategy based on iteration - if state.iteration < 5: - strategy = "diversity" # Explore early - elif state.iteration > 20: - strategy = "uncertainty" # Exploit late - else: - strategy = "hybrid" # Balance in middle - - return Action.select_samples(strategy=strategy, count=count) -``` - ---- - -## Phase 5: MCP Server - -### 5.1 MCP Server (`rosee/mcp/server.py`) - -```python -import json -from typing import Any, Dict, List - -from ..tools.registry import ToolRegistry -from ..state.workflow_state import WorkflowState - - -class ROSEEMCPServer: - """MCP server exposing ROSEE tools.""" - - def __init__(self, tool_registry: ToolRegistry): - self.tools = tool_registry - self._current_state: WorkflowState = None - - def set_state(self, state: WorkflowState): - """Update current workflow state.""" - self._current_state = state - - def get_tool_list(self) -> List[Dict]: - """Get list of available tools in MCP format.""" - return [ - { - "name": spec.name, - "description": spec.description, - "inputSchema": spec.parameters - } - for spec in self.tools.get_specs() - ] - - def call_tool(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: - """Execute a tool call.""" - tool = self.tools.get(name) - if tool is None: - return { - "error": f"Unknown tool: {name}", - "available_tools": [t.name for t in self.tools.list_tools()] - } - - if self._current_state is None: - return {"error": "No workflow state available"} - - result = tool.execute(self._current_state, **arguments) - - return { - "success": result.success, - "action_type": result.action_type.value, - "data": result.data, - "error": result.error - } - - def handle_request(self, request: Dict) -> Dict: - """Handle MCP request.""" - method = request.get("method") - - if method == "tools/list": - return { - "tools": self.get_tool_list() - } - - elif method == "tools/call": - params = request.get("params", {}) - name = params.get("name") - arguments = params.get("arguments", {}) - return { - "content": [ - { - "type": "text", - "text": json.dumps(self.call_tool(name, arguments)) - } - ] - } - - elif method == "resources/read": - # Return current state as a resource - if self._current_state: - return { - "contents": [ - { - "uri": "rosee://workflow/state", - "mimeType": "application/json", - "text": json.dumps(self._current_state.to_dict()) - } - ] - } - return {"contents": []} - - return {"error": f"Unknown method: {method}"} -``` - -### 5.2 MCP Schemas (`rosee/mcp/schemas.py`) - -```python -"""MCP protocol schemas for ROSEE.""" - -SERVER_INFO = { - "name": "rosee", - "version": "0.1.0", - "description": "ROSEE - Agent-enabled interface for ROSE workflows" -} - -CAPABILITIES = { - "tools": {}, - "resources": { - "subscribe": False, - "listChanged": False - } -} - -RESOURCE_TEMPLATES = [ - { - "uriTemplate": "rosee://workflow/state", - "name": "Workflow State", - "description": "Current state of the active learning workflow", - "mimeType": "application/json" - } -] -``` - ---- - -## Phase 6: Usage Examples - -### 6.1 Basic LLM-Controlled AL - -```python -import asyncio -from anthropic import Anthropic - -from rose.al import SequentialActiveLearner -from radical.asyncflow import WorkflowEngine, RadicalExecutionBackend - -from rosee.agents import ActiveLearningAgent -from rosee.state import WorkflowState, Action -from rosee.tools import ToolRegistry - - -async def main(): - # Setup ROSE - engine = await RadicalExecutionBackend({'resource': 'local.localhost', 'runtime': 30}) - asyncflow = await WorkflowEngine.create(engine) - learner = SequentialActiveLearner(asyncflow) - - # Define ROSE tasks (as usual) - @learner.simulation_task - async def simulation(*args, **kwargs): - return 'python sim.py' - - @learner.training_task - async def training(*args, **kwargs): - return 'python train.py' - - @learner.active_learn_task - async def active_learn(*args, **kwargs): - return 'python active.py' - - # Create LLM decision function - client = Anthropic() - tools = ToolRegistry() - - def llm_decision(state: WorkflowState) -> Action: - response = client.messages.create( - model="claude-sonnet-4-20250514", - max_tokens=1024, - system="You control an active learning workflow. Use the tools to decide what to do next.", - messages=[ - {"role": "user", "content": state.to_prompt()} - ], - tools=tools.to_anthropic_format() - ) - - # Parse tool call from response - for block in response.content: - if block.type == "tool_use": - tool = tools.get(block.name) - return tool.to_action(**block.input) - - # No tool called - default to continue - return Action.continue_iteration() - - # Create ROSEE agent - agent = ActiveLearningAgent( - learner=learner, - decision_fn=llm_decision, - decision_timeout=60.0 # 60 second timeout - ) - - # Run - result = await agent.run(max_iter=20) - print(f"Completed: {result}") - - await asyncflow.shutdown() - - -asyncio.run(main()) -``` - -### 6.2 RL Policy Decision Function - -```python -import torch -import torch.nn as nn - -from rosee.state import WorkflowState, Action - - -class RLPolicy(nn.Module): - """Simple RL policy network.""" - - def __init__(self, state_dim: int = 10, n_actions: int = 4): - super().__init__() - self.net = nn.Sequential( - nn.Linear(state_dim, 64), - nn.ReLU(), - nn.Linear(64, 32), - nn.ReLU(), - nn.Linear(32, n_actions) - ) - - def forward(self, state_vector): - return self.net(state_vector) - - -def create_rl_decision_fn(policy: RLPolicy) -> callable: - """Create decision function from RL policy.""" - - action_map = { - 0: lambda: Action.select_samples(strategy="uncertainty", count=10), - 1: lambda: Action.select_samples(strategy="diversity", count=10), - 2: lambda: Action.select_samples(strategy="hybrid", count=20), - 3: lambda: Action.stop(reason="rl_policy_decision") - } - - def decision_fn(state: WorkflowState) -> Action: - state_vector = torch.tensor(state.to_vector()).unsqueeze(0) - with torch.no_grad(): - logits = policy(state_vector) - action_idx = logits.argmax(dim=1).item() - return action_map[action_idx]() - - return decision_fn - - -# Usage -policy = RLPolicy() -policy.load_state_dict(torch.load("trained_policy.pt")) - -agent = ActiveLearningAgent( - learner=learner, - decision_fn=create_rl_decision_fn(policy) -) -``` - -### 6.3 Simple Rule-Based Decision Function - -```python -from rosee.state import WorkflowState, Action - - -def rule_based_decision(state: WorkflowState) -> Action: - """Simple rule-based decision function.""" - - # Stop if converged - if len(state.metric_history) >= 3: - recent_improvement = abs(state.metric_history[-1] - state.metric_history[-3]) - if recent_improvement < 0.001: - return Action.stop(reason="converged") - - # Stop if target reached - if state.metric_threshold and state.metric_value <= state.metric_threshold: - return Action.stop(reason="target_reached") - - # Early iterations: explore with diversity - if state.iteration < 5: - return Action.select_samples(strategy="diversity", count=20) - - # High uncertainty: exploit - if state.mean_uncertainty and state.mean_uncertainty > 0.5: - return Action.select_samples(strategy="uncertainty", count=15) - - # Default: hybrid - return Action.select_samples(strategy="hybrid", count=10) - - -agent = ActiveLearningAgent( - learner=learner, - decision_fn=rule_based_decision -) -``` - ---- - -## Implementation Order - -### Sprint 1: Foundation -1. `rosee/state/workflow_state.py` - WorkflowState dataclass -2. `rosee/state/actions.py` - Action and ActionType -3. `rosee/defaults/policies.py` - DefaultPolicy - -### Sprint 2: Tools -4. `rosee/tools/base.py` - BaseTool class -5. `rosee/tools/sample_selection.py` - SelectSamplesTool -6. `rosee/tools/hyperparameters.py` - SetHyperparametersTool -7. `rosee/tools/uncertainty.py` - GetUncertaintyTool -8. `rosee/tools/control.py` - StopTool, ContinueTool -9. `rosee/tools/registry.py` - ToolRegistry - -### Sprint 3: Agent -10. `rosee/agents/base.py` - BaseAgent -11. `rosee/agents/active_learning.py` - ActiveLearningAgent -12. Integration testing with ROSE - -### Sprint 4: MCP -13. `rosee/mcp/schemas.py` - MCP schemas -14. `rosee/mcp/server.py` - ROSEEMCPServer -15. End-to-end testing - -### Sprint 5: Examples & Docs -16. Example: LLM-controlled AL -17. Example: RL policy -18. Example: Rule-based -19. Documentation - ---- - -## Open Questions - -1. **ROSE modification**: Does ROSE need a new API for single-iteration execution, or can we wrap the existing `teach()` loop? - -2. **State access**: How do we access uncertainty scores and data counts from ROSE learner internals? - -3. **Resource tracking**: How do we get compute usage metrics from RADICAL-Pilot? - -4. **Parallel agent**: Should `ParallelActiveLearningAgent` allow per-learner decision functions? From b5735834a5cb650ff7d6fa3da5de693e23a17414 Mon Sep 17 00:00:00 2001 From: AymenFJA Date: Thu, 19 Feb 2026 18:06:38 +0100 Subject: [PATCH 06/30] adding examples --- examples/service/README.md | 197 +++++++++++++++++++++++++++++ examples/service/run_service.py | 112 ++++++++++++++++ examples/service/service_real.yaml | 24 ++++ examples/service/service_test.yaml | 23 ++++ examples/service/verify_service.py | 80 ++++++++++++ pyproject.toml | 2 +- 6 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 examples/service/README.md create mode 100644 examples/service/run_service.py create mode 100644 examples/service/service_real.yaml create mode 100644 examples/service/service_test.yaml create mode 100644 examples/service/verify_service.py diff --git a/examples/service/README.md b/examples/service/README.md new file mode 100644 index 00000000..3fad96cc --- /dev/null +++ b/examples/service/README.md @@ -0,0 +1,197 @@ +# ROSE Service Examples + +This directory contains examples for running ROSE workflows through the **ROSE Service** — a daemon-based workflow manager that accepts workflow submissions, tracks execution state, and provides real-time status updates. + +The service uses file-based IPC: the manager polls a local directory for request files, and clients (CLI or Python) write JSON requests into that directory. A shared `registry.json` file reflects the live state of all workflows. + +**Files in this directory:** + +| File | Description | +|------|-------------| +| `service_test.yaml` | Minimal test workflow using `/bin/echo` (no dependencies required) | +| `service_real.yaml` | Real workflow using `ParallelActiveLearner` with Python scripts | +| `run_service.py` | Integration example: launches, submits, monitors, and shuts down programmatically | +| `verify_service.py` | Demonstrates workflow cancellation flow | + +--- + +## Workflow YAML Format + +Both examples use a YAML file to define the workflow. The service loads this file, instantiates the appropriate learner, and registers the component tasks. + +```yaml +learner: + type: SequentialActiveLearner # or ParallelActiveLearner + +components: + simulation: + type: script # or "function" for a Python callable + path: /bin/echo + config: + args: ["Simulation Step"] + training: + type: script + path: /bin/echo + config: + args: ["Training Step"] + active_learn: + type: script + path: /bin/echo + config: + args: ["AL Step"] + +config: + max_iterations: 3 + work_dir: /tmp/rose_test +``` + +--- + +## Option 1 — CLI (Two Terminals) + +The CLI is the simplest way to interact with ROSE service. You need two terminal sessions: one to run the service daemon and one to submit and monitor workflows. + +> The `--job-id` flag identifies the service instance. If you are inside a SLURM job, it defaults to `$SLURM_JOB_ID`. For local usage, it defaults to `local_job_0`. Both terminals must use the same job ID. + +### Terminal 1 — Start the Service + +```bash +rose launch +``` + +The service starts and blocks, printing log output as workflows are received and executed. Keep this terminal open for the lifetime of the session. + +To use a custom job ID (e.g. for running multiple isolated services): + +```bash +rose launch --job-id my_session +``` + +### Terminal 2 — Submit and Monitor a Workflow + +**Submit a workflow:** + +```bash +rose submit examples/service/service_test.yaml +``` + +Output: + +``` +Submitted workflow request. +Request ID: 3f2a1b4c-... +Workflow ID: wf.3f2a1b4c +``` + +**Check workflow status:** + +```bash +rose status wf.3f2a1b4c +``` + +Output (example): + +```json +{ + "wf_id": "wf.3f2a1b4c", + "state": "running", + "workflow_file": "/path/to/service_test.yaml", + "start_time": 1700000000.0, + "end_time": null, + "stats": { + "iteration": 2, + "metric_value": null + }, + "error": null +} +``` + +**List all workflows:** + +```bash +rose status +``` + +**Cancel a running workflow:** + +```bash +rose cancel wf.3f2a1b4c +``` + +**Shut down the service when done:** + +```bash +rose shutdown +``` + +> The service in Terminal 1 will exit gracefully after receiving the shutdown request. + +If you launched with a custom `--job-id`, pass the same flag to all client commands: + +```bash +rose submit --job-id my_session examples/service/service_test.yaml +rose status --job-id my_session +rose shutdown --job-id my_session +``` + +--- + +## Option 2 — Python Client + +Use `ServiceClient` from `rose.service.client` to drive the service programmatically — useful for integration scripts, notebooks, or automated pipelines. + +See [`run_service.py`](run_service.py) for a complete working example. It covers the full lifecycle: + +1. Start `ServiceManager` as a background `asyncio` task +2. Initialize `ServiceClient` with the same job ID +3. Call `client.submit_workflow(path)` to submit a YAML workflow +4. Derive the workflow ID with `ServiceClient.get_wf_id(req_id)` +5. Poll `client.get_workflow_status(wf_id)` until the state reaches `COMPLETED`, `FAILED`, or `CANCELED` +6. Call `client.shutdown()` to stop the service + +Run it with: + +```bash +python examples/service/run_service.py +``` + +See [`verify_service.py`](verify_service.py) for an example of cancellation via `client.cancel_workflow(wf_id)`. + +**Key `ServiceClient` methods:** + +| Method | Description | +|--------|-------------| +| `submit_workflow(path)` | Submit a YAML workflow file; returns a `req_id` | +| `ServiceClient.get_wf_id(req_id)` | Derive the workflow ID from a request ID | +| `get_workflow_status(wf_id)` | Return the current state dict for a workflow | +| `list_workflows()` | Return all workflows from the registry | +| `cancel_workflow(wf_id)` | Request cancellation of a running workflow | +| `shutdown()` | Send a graceful shutdown request to the service | + +--- + +## Option 3 — REST API *(upcoming)* + +> **Not yet implemented.** A REST API for ROSE service is planned for a future release. + +The REST API will expose the same operations as the CLI and Python client over HTTP, making it possible to submit and monitor workflows from any language or tool (e.g. `curl`, JavaScript, or remote machines). + +Planned endpoints: + +``` +POST /workflows Submit a new workflow +GET /workflows List all workflows +GET /workflows/{wf_id} Get status of a specific workflow +DELETE /workflows/{wf_id} Cancel a workflow +POST /shutdown Gracefully stop the service +``` + +When available, a workflow submission will look like: + +```bash +curl -X POST http://localhost:8080/workflows \ + -H "Content-Type: application/json" \ + -d '{"workflow_file": "/path/to/workflow.yaml"}' +``` + +Stay tuned for updates. diff --git a/examples/service/run_service.py b/examples/service/run_service.py new file mode 100644 index 00000000..4bf98e43 --- /dev/null +++ b/examples/service/run_service.py @@ -0,0 +1,112 @@ +import asyncio +import os +import time +import json +import shutil +import logging +from pathlib import Path +from rose.service.manager import ServiceManager +from rose.service.client import ServiceClient + +# Configure logging to see what's happening +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger("run_service") + +# Job ID for this service instance +JOB_ID = "rose_service_run" +SERVICE_ROOT = Path.home() / ".rose" / "services" / JOB_ID + +def cleanup(): + if SERVICE_ROOT.exists(): + logger.info(f"Cleaning up previous service root at {SERVICE_ROOT}") + shutil.rmtree(SERVICE_ROOT) + +async def run_workflow(): + print(f"--- Starting ROSE Service for Job {JOB_ID} ---") + cleanup() + + # 1. Start Service Manager in a background task + manager = ServiceManager(JOB_ID) + service_task = asyncio.create_task(manager.run()) + + # Wait for service initialization + await asyncio.sleep(1) + + # 2. Initialize Client + client = ServiceClient(JOB_ID) + + # 3. Submit the realistic workflow + wf_path = "service_real.yaml" + if not os.path.exists(wf_path): + print(f"Error: Workflow file not found at {wf_path}") + await manager.shutdown() + return + + print(f"Submitting workflow: {wf_path}") + req_id = client.submit_workflow(wf_path) + print(f"Submitted. Request ID: {req_id}") + + # 4. Wait for workflow to be picked up and assigned a wf_id + wf_id = None + print("Waiting for service to assign Workflow ID...") + for _ in range(20): + await asyncio.sleep(1) + registry = client.list_workflows() + if registry: + wf_id = list(registry.keys())[0] + print(f"Assigned Workflow ID: {wf_id}") + break + + if not wf_id: + print("Error: Workflow was not picked up by the service.") + await manager.shutdown() + return + + # 5. Monitor progress until completion + print(f"Monitoring workflow {wf_id}...") + last_state = None + while True: + status = client.get_workflow_status(wf_id) + if not status: + print("Error: Workflow status lost.") + break + + current_state = status.get('state') + if current_state != last_state: + print(f"Status Change: {current_state}") + last_state = current_state + + if current_state in ["COMPLETED", "FAILED", "CANCELED"]: + print(f"Workflow reached terminal state: {current_state}") + if current_state == "FAILED": + print(f"Error Details: {status.get('error')}") + break + + # Optional: Print iteration progress if available in stats + stats = status.get('stats', {}) + if 'iteration' in stats: + print(f" Iteration: {stats['iteration']} | Metric: {stats.get('metric_value', 'N/A')}", end='\r') + + await asyncio.sleep(2) + + # 6. Final Report + status = client.get_workflow_status(wf_id) + print("\n--- Final Workflow Status ---") + print(json.dumps(status, indent=2)) + + # 7. Graceful Shutdown + print("Shutting down service...") + await manager.shutdown() + try: + # Give some time for internal tasks to finish before canceling the service loop + await asyncio.wait_for(service_task, timeout=5) + except (asyncio.TimeoutError, asyncio.CancelledError): + pass + + print("--- Service Run Finished ---") + +if __name__ == "__main__": + try: + asyncio.run(run_workflow()) + except KeyboardInterrupt: + print("\nInterrupted by user.") diff --git a/examples/service/service_real.yaml b/examples/service/service_real.yaml new file mode 100644 index 00000000..c4544d43 --- /dev/null +++ b/examples/service/service_real.yaml @@ -0,0 +1,24 @@ +learner: + path: rose.al.active_learner.ParallelActiveLearner + +components: + simulation: + type: script + path: /home/aymen/ve/raas/bin/python3 + config: + args: ["/home/aymen/RADICAL/ROSE-AS-A-SERVICE/ROSE/examples/active_learn/basic/sim.py"] + training: + type: script + path: /home/aymen/ve/raas/bin/python3 + config: + args: ["/home/aymen/RADICAL/ROSE-AS-A-SERVICE/ROSE/examples/active_learn/basic/train.py"] + active_learn: + type: script + path: /home/aymen/ve/raas/bin/python3 + config: + args: ["/home/aymen/RADICAL/ROSE-AS-A-SERVICE/ROSE/examples/active_learn/basic/active.py"] + +config: + parallel_learners: 4 + max_iterations: 10 + work_dir: /tmp/rose_test diff --git a/examples/service/service_test.yaml b/examples/service/service_test.yaml new file mode 100644 index 00000000..41451c03 --- /dev/null +++ b/examples/service/service_test.yaml @@ -0,0 +1,23 @@ +learner: + type: SequentialActiveLearner + +components: + simulation: + type: script + path: /bin/echo + config: + args: ["Simulation Step"] + training: + type: script + path: /bin/echo + config: + args: ["Training Step"] + active_learn: + type: script + path: /bin/echo + config: + args: ["AL Step"] + +config: + max_iterations: 3 + work_dir: /tmp/rose_test diff --git a/examples/service/verify_service.py b/examples/service/verify_service.py new file mode 100644 index 00000000..472f63d2 --- /dev/null +++ b/examples/service/verify_service.py @@ -0,0 +1,80 @@ +import asyncio +import os +import time +import json +import shutil +from pathlib import Path +from rose.service.manager import ServiceManager +from rose.service.client import ServiceClient + +# Mock Job ID +JOB_ID = "test_job_123" +SERVICE_ROOT = Path.home() / ".rose" / "services" / JOB_ID + +def cleanup(): + if SERVICE_ROOT.exists(): + shutil.rmtree(SERVICE_ROOT) + +async def run_verification(): + print(f"--- Starting Verification for Job {JOB_ID} ---") + cleanup() + + # 1. Start Service in Background Task + manager = ServiceManager(JOB_ID) + service_task = asyncio.create_task(manager.run()) + + # Allow service to init + await asyncio.sleep(2) + + # 2. Initialize Client + client = ServiceClient(JOB_ID) + + # 3. Submit Workflow + wf_path = "service_real.yaml" + print(f"Submitting {wf_path}...") + req_id = client.submit_workflow(wf_path) + print(f"Submitted. Request ID: {req_id}") + + # 4. Poll for Status until Running + wf_id = None + for _ in range(10): + await asyncio.sleep(1) + registry = client.list_workflows() + if registry: + wf_id = list(registry.keys())[0] + state = registry[wf_id]['state'] + print(f"Workflow {wf_id} State: {state}") + if state in ["RUNNING", "COMPLETED"]: + break + + if not wf_id: + print("Failed to get workflow ID") + await manager.shutdown() + return + + # 5. Cancel Workflow (if still running) + print(f"Canceling {wf_id}...") + client.cancel_workflow(wf_id) + + # 6. Check for Canceled State + for _ in range(5): + await asyncio.sleep(1) + status = client.get_workflow_status(wf_id) + print(f"Workflow {wf_id} State: {status['state']}") + if status['state'] == "CANCELED": + print("SUCCESS: Workflow Canceled") + break + if status['state'] == "COMPLETED": + print("Workflow finished before cancel (acceptable for short test)") + break + + # Shutdown + await manager.shutdown() + try: + await service_task + except asyncio.CancelledError: + pass + print("--- Verification Finished ---") + +if __name__ == "__main__": + asyncio.run(run_verification()) diff --git a/pyproject.toml b/pyproject.toml index 78d3d88d..b551c9bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ maintainers = [ readme = "README.md" requires-python = ">=3.9" -dependencies = ["numpy", "radical.asyncflow", "radical.pilot"] +dependencies = ["numpy", "radical.asyncflow", "radical.pilot", "PyYAML"] [project.urls] Homepage = "https://github.com/radical-cybertools/ROSE" From 171d503e31929a5688ccdbb1a9b6c9eba1d720e1 Mon Sep 17 00:00:00 2001 From: AymenFJA Date: Mon, 23 Feb 2026 21:57:08 +0100 Subject: [PATCH 07/30] This commit: - Adds radical.edge rose plugin - ROSE Rest API via the introduced plguin --- examples/service/README.md | 4 +- rose/service/api/reset.py | 464 +++++++++++++++++++++++++++++++++++++ 2 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 rose/service/api/reset.py diff --git a/examples/service/README.md b/examples/service/README.md index 3fad96cc..60c588f5 100644 --- a/examples/service/README.md +++ b/examples/service/README.md @@ -64,7 +64,7 @@ The service starts and blocks, printing log output as workflows are received and To use a custom job ID (e.g. for running multiple isolated services): ```bash -rose launch --job-id my_session +rose launch --job-id job.000001 ``` ### Terminal 2 — Submit and Monitor a Workflow @@ -72,7 +72,7 @@ rose launch --job-id my_session **Submit a workflow:** ```bash -rose submit examples/service/service_test.yaml +rose submit --job-id job.000001 examples/service/service_test.yaml ``` Output: diff --git a/rose/service/api/reset.py b/rose/service/api/reset.py new file mode 100644 index 00000000..b25dec4f --- /dev/null +++ b/rose/service/api/reset.py @@ -0,0 +1,464 @@ + +__author__ = 'RADICAL Development Team' +__email__ = 'radical@radical-project.org' +__copyright__ = 'Copyright 2024, RADICAL@Rutgers' +__license__ = 'MIT' + + +import asyncio +import uuid +import logging + +from fastapi import FastAPI, HTTPException, Request +from starlette.responses import JSONResponse + +from radical.edge.plugin_session_base import PluginSession +from radical.edge.plugin_base import Plugin +from radical.edge.client import PluginClient + +from rose.service.client import ServiceClient + + +log = logging.getLogger("radical.edge") + + +# ------------------------------------------------------------------------------ +# +class RoseSession(PluginSession): + """ + ROSE session (service-side). + + Wraps a ``ServiceClient`` instance, forwarding workflow submission, + status queries, cancellation, and service shutdown to a running ROSE + service identified by its job ID. + """ + + # -------------------------------------------------------------------------- + # + def __init__(self, sid: str, job_id: str = 'local_job_0'): + """ + Initialize a RoseSession. + + Args: + sid (str): Unique session identifier assigned by the plugin. + job_id (str): The ROSE service job ID to connect to. + Defaults to 'local_job_0' (local, non-SLURM usage). + """ + super().__init__(sid) + + self._job_id = job_id + self._client = ServiceClient(job_id) + + + # -------------------------------------------------------------------------- + # + async def submit_workflow(self, workflow_file: str) -> dict: + """ + Submit a workflow YAML file to the ROSE service. + + Args: + workflow_file (str): Absolute or relative path to the workflow YAML. + + Returns: + dict: ``{req_id, wf_id}`` — the request ID and the derived workflow ID. + """ + self._check_active() + + req_id = await asyncio.to_thread(self._client.submit_workflow, + workflow_file) + wf_id = ServiceClient.get_wf_id(req_id) + + return {'req_id': req_id, 'wf_id': wf_id} + + + # -------------------------------------------------------------------------- + # + async def get_workflow_status(self, wf_id: str) -> dict: + """ + Return the current status of a workflow. + + Args: + wf_id (str): The workflow ID (e.g. ``wf.3f2a1b4c``). + + Returns: + dict: Workflow state dictionary from the service registry. + + Raises: + HTTPException(404): If the workflow ID is not found. + """ + self._check_active() + + status = await asyncio.to_thread(self._client.get_workflow_status, + wf_id) + if not status: + raise HTTPException(status_code=404, + detail=f"workflow '{wf_id}' not found") + + return status + + + # -------------------------------------------------------------------------- + # + async def list_workflows(self) -> dict: + """ + List all workflows tracked by the ROSE service. + + Returns: + dict: Full registry mapping ``wf_id → state dict``. + """ + self._check_active() + + return await asyncio.to_thread(self._client.list_workflows) + + + # -------------------------------------------------------------------------- + # + async def cancel_workflow(self, wf_id: str) -> dict: + """ + Request cancellation of a running workflow. + + Args: + wf_id (str): The workflow ID to cancel. + + Returns: + dict: ``{req_id, wf_id}`` confirming the cancellation request. + """ + self._check_active() + + req_id = await asyncio.to_thread(self._client.cancel_workflow, wf_id) + + return {'req_id': req_id, 'wf_id': wf_id} + + + # -------------------------------------------------------------------------- + # + async def shutdown(self) -> dict: + """ + Send a graceful shutdown request to the ROSE service. + + Returns: + dict: ``{req_id}`` confirming the shutdown request was queued. + """ + self._check_active() + + req_id = await asyncio.to_thread(self._client.shutdown) + + return {'req_id': req_id} + + + # -------------------------------------------------------------------------- + # + async def close(self) -> dict: + """ + Close this session. ServiceClient is stateless so no teardown needed. + """ + self._client = None + + return await super().close() + + +# ------------------------------------------------------------------------------ +# +class RoseClient(PluginClient): + """ + Application-side client for the ROSE plugin. + + Provides a thin sync wrapper over the HTTP endpoints exposed by + ``PluginRose``, mirroring the same operations available through the + ``rose`` CLI and ``ServiceClient``. + """ + + # -------------------------------------------------------------------------- + # + def register_session(self, job_id: str = 'local_job_0'): + """ + Register a session with the ROSE plugin, binding it to a job ID. + + Args: + job_id (str): The ROSE service job ID to connect to. + Defaults to 'local_job_0'. + """ + resp = self._http.post(self._url('register_session'), + json={'job_id': job_id}) + resp.raise_for_status() + self._sid = resp.json()['sid'] + + + # -------------------------------------------------------------------------- + # + def submit_workflow(self, workflow_file: str) -> dict: + """ + Submit a workflow YAML file. + + Args: + workflow_file (str): Path to the workflow YAML file. + + Returns: + dict: ``{req_id, wf_id}``. + """ + if not self.sid: + raise RuntimeError('No active session') + + resp = self._http.post(self._url(f'submit/{self.sid}'), + json={'workflow_file': workflow_file}) + resp.raise_for_status() + + return resp.json() + + + # -------------------------------------------------------------------------- + # + def get_workflow_status(self, wf_id: str) -> dict: + """ + Get the current status of a workflow. + + Args: + wf_id (str): Workflow ID. + + Returns: + dict: Workflow state dictionary. + """ + if not self.sid: + raise RuntimeError('No active session') + + resp = self._http.get(self._url(f'status/{self.sid}/{wf_id}')) + resp.raise_for_status() + + return resp.json() + + + # -------------------------------------------------------------------------- + # + def list_workflows(self) -> dict: + """ + List all workflows in the connected ROSE service. + + Returns: + dict: Registry mapping ``wf_id → state dict``. + """ + if not self.sid: + raise RuntimeError('No active session') + + resp = self._http.get(self._url(f'workflows/{self.sid}')) + resp.raise_for_status() + + return resp.json() + + + # -------------------------------------------------------------------------- + # + def cancel_workflow(self, wf_id: str) -> dict: + """ + Cancel a running workflow. + + Args: + wf_id (str): Workflow ID to cancel. + + Returns: + dict: ``{req_id, wf_id}``. + """ + if not self.sid: + raise RuntimeError('No active session') + + resp = self._http.post(self._url(f'cancel/{self.sid}/{wf_id}')) + resp.raise_for_status() + + return resp.json() + + + # -------------------------------------------------------------------------- + # + def shutdown(self) -> dict: + """ + Send a shutdown request to the ROSE service. + + Returns: + dict: ``{req_id}``. + """ + if not self.sid: + raise RuntimeError('No active session') + + resp = self._http.post(self._url(f'shutdown/{self.sid}')) + resp.raise_for_status() + + return resp.json() + + +# ------------------------------------------------------------------------------ +# +class PluginRose(Plugin): + """ + ROSE plugin for RADICAL-Edge. + + Exposes ROSE-as-a-Service workflow management via REST endpoints, + enabling remote submission and monitoring of Active Learning workflows + through the RADICAL-Edge bridge infrastructure. + + Standard routes inherited from Plugin: + - POST /rose/register_session + - POST /rose/unregister_session/{sid} + - GET /rose/echo/{sid} + - GET /rose/version + - GET /rose/list_sessions + + ROSE-specific routes: + - POST /rose/submit/{sid} + - GET /rose/status/{sid}/{wf_id} + - GET /rose/workflows/{sid} + - POST /rose/cancel/{sid}/{wf_id} + - POST /rose/shutdown/{sid} + """ + + plugin_name = 'rose' + session_class = RoseSession + client_class = RoseClient + version = '0.1.0' + + + # -------------------------------------------------------------------------- + # + def __init__(self, app: FastAPI, instance_name: str = 'rose'): + """ + Initialize the ROSE plugin, registering all routes. + + Args: + app (FastAPI): The FastAPI application instance. + instance_name (str): Plugin namespace. Defaults to 'rose'. + """ + super().__init__(app, instance_name) + + self.add_route_post('submit/{sid}', self.submit_workflow) + self.add_route_get ('status/{sid}/{wf_id}', self.get_workflow_status) + self.add_route_get ('workflows/{sid}', self.list_workflows) + self.add_route_post('cancel/{sid}/{wf_id}', self.cancel_workflow) + self.add_route_post('shutdown/{sid}', self.shutdown) + + self._log_routes() + + + # -------------------------------------------------------------------------- + # + async def register_session(self, request: Request) -> JSONResponse: + """ + Register a new ROSE session, binding it to a specific job ID. + + Overrides the base implementation to accept an optional ``job_id`` + from the request body (defaults to ``'local_job_0'``). + + Args: + request (Request): JSON body may contain ``{"job_id": "..."}`` + + Returns: + JSONResponse: ``{sid}`` — the assigned session ID. + """ + body = {} + try: + body = await request.json() + except Exception: + pass + + job_id = body.get('job_id', 'local_job_0') + + async with self._id_lock: + sid = f'session.{uuid.uuid4().hex[:8]}' + + self._sessions[sid] = self._create_session(sid, job_id=job_id) + log.info(f'[{self.instance_name}] Registered session {sid} ' + f'(job_id={job_id})') + + return JSONResponse({'sid': sid}) + + + # -------------------------------------------------------------------------- + # + async def submit_workflow(self, request: Request) -> JSONResponse: + """ + Submit a workflow YAML file to the ROSE service. + + Args: + request (Request): Path param ``sid``. + JSON body: ``{"workflow_file": "/path/to/wf.yaml"}`` + + Returns: + JSONResponse: ``{req_id, wf_id}`` + """ + sid = request.path_params['sid'] + data = await request.json() + + return await self._forward(sid, RoseSession.submit_workflow, + workflow_file=data.get('workflow_file')) + + + # -------------------------------------------------------------------------- + # + async def get_workflow_status(self, request: Request) -> JSONResponse: + """ + Return the status of a specific workflow. + + Args: + request (Request): Path params ``sid``, ``wf_id``. + + Returns: + JSONResponse: Workflow state dictionary. + """ + sid = request.path_params['sid'] + wf_id = request.path_params['wf_id'] + + return await self._forward(sid, RoseSession.get_workflow_status, + wf_id=wf_id) + + + # -------------------------------------------------------------------------- + # + async def list_workflows(self, request: Request) -> JSONResponse: + """ + List all workflows tracked by the ROSE service. + + Args: + request (Request): Path param ``sid``. + + Returns: + JSONResponse: Registry dict ``{wf_id → state dict}``. + """ + sid = request.path_params['sid'] + + return await self._forward(sid, RoseSession.list_workflows) + + + # -------------------------------------------------------------------------- + # + async def cancel_workflow(self, request: Request) -> JSONResponse: + """ + Cancel a running workflow. + + Args: + request (Request): Path params ``sid``, ``wf_id``. + + Returns: + JSONResponse: ``{req_id, wf_id}`` + """ + sid = request.path_params['sid'] + wf_id = request.path_params['wf_id'] + + return await self._forward(sid, RoseSession.cancel_workflow, + wf_id=wf_id) + + + # -------------------------------------------------------------------------- + # + async def shutdown(self, request: Request) -> JSONResponse: + """ + Send a graceful shutdown request to the ROSE service. + + Args: + request (Request): Path param ``sid``. + + Returns: + JSONResponse: ``{req_id}`` + """ + sid = request.path_params['sid'] + + return await self._forward(sid, RoseSession.shutdown) + + +# ------------------------------------------------------------------------------ From b7a1db866a40da0164446ea5f967ec0ad07f7fb5 Mon Sep 17 00:00:00 2001 From: AymenFJA Date: Mon, 23 Feb 2026 22:23:28 +0100 Subject: [PATCH 08/30] fix name --- rose/service/api/{reset.py => rest.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename rose/service/api/{reset.py => rest.py} (100%) diff --git a/rose/service/api/reset.py b/rose/service/api/rest.py similarity index 100% rename from rose/service/api/reset.py rename to rose/service/api/rest.py From 7818cfb9f23ad3990f7df330a31277a491ef83da Mon Sep 17 00:00:00 2001 From: Andre Merzky Date: Wed, 4 Mar 2026 21:05:02 -0500 Subject: [PATCH 09/30] simplification --- pyproject.toml | 3 + rose/service/api/rest.py | 459 ++++++++++++++++++++++++--------------- rose/service/manager.py | 5 +- 3 files changed, 287 insertions(+), 180 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b551c9bf..a1be67c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,9 @@ Documentation = "https://radical-cybertools.github.io/ROSE/" [project.scripts] rose = "rose.service.api.cli:main" +[project.entry-points."radical.edge.plugins"] +rose = "rose.service.api.rest:PluginRose" + [project.optional-dependencies] lint = ["ruff"] diff --git a/rose/service/api/rest.py b/rose/service/api/rest.py index b25dec4f..3bc29952 100644 --- a/rose/service/api/rest.py +++ b/rose/service/api/rest.py @@ -7,7 +7,9 @@ import asyncio import uuid +import time import logging +from typing import Dict, Any, Optional from fastapi import FastAPI, HTTPException, Request from starlette.responses import JSONResponse @@ -15,8 +17,16 @@ from radical.edge.plugin_session_base import PluginSession from radical.edge.plugin_base import Plugin from radical.edge.client import PluginClient +from radical.edge.ui_schema import UIConfig, UIForm, UIField, \ + UIFormSubmit, UIMonitor, \ + UINotifications -from rose.service.client import ServiceClient +from radical.asyncflow import WorkflowEngine, LocalExecutionBackend + +from rose.al.active_learner import SequentialActiveLearner, ParallelActiveLearner +from rose.learner import LearnerConfig, TaskConfig +from rose.service.models import Workflow, WorkflowState +from rose.service.manager import WorkflowLoader log = logging.getLogger("radical.edge") @@ -28,47 +38,176 @@ class RoseSession(PluginSession): """ ROSE session (service-side). - Wraps a ``ServiceClient`` instance, forwarding workflow submission, - status queries, cancellation, and service shutdown to a running ROSE - service identified by its job ID. + Directly manages workflow execution using AsyncFlow, eliminating the need + for a separate ServiceManager process. """ # -------------------------------------------------------------------------- # - def __init__(self, sid: str, job_id: str = 'local_job_0'): + def __init__(self, sid: str): """ Initialize a RoseSession. Args: - sid (str): Unique session identifier assigned by the plugin. - job_id (str): The ROSE service job ID to connect to. - Defaults to 'local_job_0' (local, non-SLURM usage). + sid (str): Unique session identifier assigned by the plugin. """ super().__init__(sid) - self._job_id = job_id - self._client = ServiceClient(job_id) + self._workflows: Dict[str, Workflow] = {} + self._learner_tasks: Dict[str, asyncio.Task] = {} + self._engine: Optional[WorkflowEngine] = None + self._engine_lock = asyncio.Lock() + self._initialized = False + + + # -------------------------------------------------------------------------- + # + async def _ensure_engine(self): + """Lazily initialize the workflow engine.""" + if self._engine is not None: + return + + async with self._engine_lock: + if self._engine is not None: + return + + log.info(f'[{self.sid}] Initializing workflow engine') + backend = LocalExecutionBackend() + self._engine = await WorkflowEngine.create(backend) + self._initialized = True + log.info(f'[{self.sid}] Workflow engine ready') # -------------------------------------------------------------------------- # async def submit_workflow(self, workflow_file: str) -> dict: """ - Submit a workflow YAML file to the ROSE service. + Submit a workflow YAML file for execution. Args: workflow_file (str): Absolute or relative path to the workflow YAML. Returns: - dict: ``{req_id, wf_id}`` — the request ID and the derived workflow ID. + dict: ``{wf_id}`` — the workflow ID. """ self._check_active() + await self._ensure_engine() + + # Generate workflow ID + wf_id = f'wf.{uuid.uuid4().hex[:8]}' - req_id = await asyncio.to_thread(self._client.submit_workflow, - workflow_file) - wf_id = ServiceClient.get_wf_id(req_id) + # Create workflow record + wf = Workflow( + wf_id=wf_id, + state=WorkflowState.SUBMITTED, + workflow_file=workflow_file + ) + self._workflows[wf_id] = wf - return {'req_id': req_id, 'wf_id': wf_id} + # Notify submission + if self._notify: + self._notify('workflow_state', { + 'wf_id': wf_id, + 'state': 'SUBMITTED', + 'workflow_file': workflow_file + }) + + # Start workflow execution in background + task = asyncio.create_task(self._run_workflow(wf)) + self._learner_tasks[wf_id] = task + + log.info(f'[{self.sid}] Submitted workflow {wf_id}: {workflow_file}') + return {'wf_id': wf_id} + + + # -------------------------------------------------------------------------- + # + async def _run_workflow(self, wf: Workflow): + """Execute a workflow (runs as background task).""" + wf_id = wf.wf_id + + try: + # Initialize + wf.state = WorkflowState.INITIALIZING + self._notify_state(wf) + + # Load workflow definition + wf_def = WorkflowLoader.load_yaml(wf.workflow_file) + learner, initial_config = WorkflowLoader.create_learner( + wf_id, wf_def, self._engine + ) + wf.learner_instance = learner + + # Run + wf.state = WorkflowState.RUNNING + wf.start_time = time.time() + self._notify_state(wf) + + config = wf_def.get('config', {}) + learner_cfg = wf_def.get('learner', {}) + max_iter = config.get('max_iterations', + learner_cfg.get('max_iterations', 10)) + + log.info(f'[{self.sid}] Running workflow {wf_id} ' + f'(max_iterations={max_iter})') + + if isinstance(learner, ParallelActiveLearner): + parallel = config.get('parallel_learners', + learner_cfg.get('parallel_learners', 2)) + configs = [initial_config] * parallel if initial_config else None + + results = await learner.start( + parallel_learners=parallel, + max_iter=max_iter, + learner_configs=configs + ) + wf.stats = {'parallel_results': [str(r) for r in results]} + + else: + # Sequential learner - async iterator + async for state in learner.start( + max_iter=max_iter, + initial_config=initial_config + ): + wf.stats = state.to_dict() + log.info(f'[{self.sid}] {wf_id} iteration {state.iteration} ' + f'(metric={state.metric_value})') + self._notify_state(wf) + + # Completed + wf.state = WorkflowState.COMPLETED + wf.end_time = time.time() + log.info(f'[{self.sid}] Workflow {wf_id} completed') + + except asyncio.CancelledError: + wf.state = WorkflowState.CANCELED + wf.end_time = time.time() + log.info(f'[{self.sid}] Workflow {wf_id} canceled') + + except Exception as e: + wf.state = WorkflowState.FAILED + wf.error = str(e) + wf.end_time = time.time() + log.error(f'[{self.sid}] Workflow {wf_id} failed: {e}') + import traceback + traceback.print_exc() + + finally: + self._notify_state(wf) + self._learner_tasks.pop(wf_id, None) + + + # -------------------------------------------------------------------------- + # + def _notify_state(self, wf: Workflow): + """Send workflow state notification.""" + if self._notify: + self._notify('workflow_state', { + 'wf_id': wf.wf_id, + 'state': wf.state.value, + 'stats': wf.stats, + 'error': wf.error + }) # -------------------------------------------------------------------------- @@ -78,81 +217,109 @@ async def get_workflow_status(self, wf_id: str) -> dict: Return the current status of a workflow. Args: - wf_id (str): The workflow ID (e.g. ``wf.3f2a1b4c``). + wf_id (str): The workflow ID. Returns: - dict: Workflow state dictionary from the service registry. + dict: Workflow state dictionary. Raises: HTTPException(404): If the workflow ID is not found. """ self._check_active() - status = await asyncio.to_thread(self._client.get_workflow_status, - wf_id) - if not status: + wf = self._workflows.get(wf_id) + if not wf: raise HTTPException(status_code=404, detail=f"workflow '{wf_id}' not found") - return status + return wf.to_dict() # -------------------------------------------------------------------------- # async def list_workflows(self) -> dict: """ - List all workflows tracked by the ROSE service. + List all workflows in this session. Returns: - dict: Full registry mapping ``wf_id → state dict``. + dict: Mapping ``wf_id → state dict``. """ self._check_active() - return await asyncio.to_thread(self._client.list_workflows) + return {wf_id: wf.to_dict() for wf_id, wf in self._workflows.items()} # -------------------------------------------------------------------------- # async def cancel_workflow(self, wf_id: str) -> dict: """ - Request cancellation of a running workflow. + Cancel a running workflow. Args: wf_id (str): The workflow ID to cancel. Returns: - dict: ``{req_id, wf_id}`` confirming the cancellation request. + dict: ``{wf_id}`` confirming the cancellation. """ self._check_active() - req_id = await asyncio.to_thread(self._client.cancel_workflow, wf_id) + wf = self._workflows.get(wf_id) + if not wf: + raise HTTPException(status_code=404, + detail=f"workflow '{wf_id}' not found") - return {'req_id': req_id, 'wf_id': wf_id} + if wf.state not in (WorkflowState.RUNNING, WorkflowState.INITIALIZING, + WorkflowState.SUBMITTED): + raise HTTPException(status_code=400, + detail=f"workflow '{wf_id}' not running") + # Stop the learner + if wf.learner_instance: + wf.learner_instance.stop() - # -------------------------------------------------------------------------- - # - async def shutdown(self) -> dict: - """ - Send a graceful shutdown request to the ROSE service. + # Cancel the task + task = self._learner_tasks.get(wf_id) + if task and not task.done(): + task.cancel() - Returns: - dict: ``{req_id}`` confirming the shutdown request was queued. - """ - self._check_active() + log.info(f'[{self.sid}] Canceling workflow {wf_id}') - req_id = await asyncio.to_thread(self._client.shutdown) + if self._notify: + self._notify('workflow_state', { + 'wf_id': wf_id, + 'state': 'CANCELING' + }) - return {'req_id': req_id} + return {'wf_id': wf_id} # -------------------------------------------------------------------------- # async def close(self) -> dict: """ - Close this session. ServiceClient is stateless so no teardown needed. + Close this session, stopping all workflows and cleaning up. """ - self._client = None + log.info(f'[{self.sid}] Closing session') + + # Stop all learners + for wf in self._workflows.values(): + if wf.learner_instance and wf.state == WorkflowState.RUNNING: + wf.learner_instance.stop() + + # Cancel all tasks + for task in self._learner_tasks.values(): + if not task.done(): + task.cancel() + + if self._learner_tasks: + await asyncio.gather(*self._learner_tasks.values(), + return_exceptions=True) + self._learner_tasks.clear() + + # Shutdown engine + if self._engine: + await self._engine.shutdown() + self._engine = None return await super().close() @@ -164,24 +331,31 @@ class RoseClient(PluginClient): Application-side client for the ROSE plugin. Provides a thin sync wrapper over the HTTP endpoints exposed by - ``PluginRose``, mirroring the same operations available through the - ``rose`` CLI and ``ServiceClient``. + ``PluginRose``. """ # -------------------------------------------------------------------------- # - def register_session(self, job_id: str = 'local_job_0'): + def on_workflow_state(self, callback): """ - Register a session with the ROSE plugin, binding it to a job ID. + Register a callback for workflow state change notifications. Args: - job_id (str): The ROSE service job ID to connect to. - Defaults to 'local_job_0'. + callback: A callable(topic, data) to invoke on state changes. """ - resp = self._http.post(self._url('register_session'), - json={'job_id': job_id}) - resp.raise_for_status() - self._sid = resp.json()['sid'] + self.register_notification_callback(callback) + + + # -------------------------------------------------------------------------- + # + def off_workflow_state(self, callback): + """ + Unregister a workflow state change callback. + + Args: + callback: The callback to unregister. + """ + self.unregister_notification_callback(callback) # -------------------------------------------------------------------------- @@ -194,7 +368,7 @@ def submit_workflow(self, workflow_file: str) -> dict: workflow_file (str): Path to the workflow YAML file. Returns: - dict: ``{req_id, wf_id}``. + dict: ``{wf_id}``. """ if not self.sid: raise RuntimeError('No active session') @@ -231,7 +405,7 @@ def get_workflow_status(self, wf_id: str) -> dict: # def list_workflows(self) -> dict: """ - List all workflows in the connected ROSE service. + List all workflows in the session. Returns: dict: Registry mapping ``wf_id → state dict``. @@ -255,7 +429,7 @@ def cancel_workflow(self, wf_id: str) -> dict: wf_id (str): Workflow ID to cancel. Returns: - dict: ``{req_id, wf_id}``. + dict: ``{wf_id}``. """ if not self.sid: raise RuntimeError('No active session') @@ -266,53 +440,72 @@ def cancel_workflow(self, wf_id: str) -> dict: return resp.json() - # -------------------------------------------------------------------------- - # - def shutdown(self) -> dict: - """ - Send a shutdown request to the ROSE service. - - Returns: - dict: ``{req_id}``. - """ - if not self.sid: - raise RuntimeError('No active session') - - resp = self._http.post(self._url(f'shutdown/{self.sid}')) - resp.raise_for_status() - - return resp.json() - - # ------------------------------------------------------------------------------ # class PluginRose(Plugin): """ ROSE plugin for RADICAL-Edge. - Exposes ROSE-as-a-Service workflow management via REST endpoints, - enabling remote submission and monitoring of Active Learning workflows - through the RADICAL-Edge bridge infrastructure. + Exposes workflow management via REST endpoints, with embedded execution + (no separate ServiceManager process required). - Standard routes inherited from Plugin: + Routes: - POST /rose/register_session - POST /rose/unregister_session/{sid} - - GET /rose/echo/{sid} - - GET /rose/version - - GET /rose/list_sessions - - ROSE-specific routes: - POST /rose/submit/{sid} - GET /rose/status/{sid}/{wf_id} - GET /rose/workflows/{sid} - POST /rose/cancel/{sid}/{wf_id} - - POST /rose/shutdown/{sid} """ plugin_name = 'rose' session_class = RoseSession client_class = RoseClient - version = '0.1.0' + version = '0.2.0' + session_ttl = 0 # No timeout - workflows can run for hours/days + + ui_config = UIConfig( + icon='🌹', + title='ROSE Active Learning', + description='Submit and monitor Active Learning workflows', + refresh_button=True, + forms=[ + UIForm( + id='submit', + title='Submit Workflow', + layout='single', + fields=[ + UIField( + name='workflow_file', + type='text', + label='Workflow File', + placeholder='/path/to/workflow.yaml', + required=True + ) + ], + submit=UIFormSubmit( + label='Submit', + style='success', + endpoint='submit/{sid}' + ) + ) + ], + monitors=[ + UIMonitor( + id='workflows', + title='Workflows', + type='task_list', + css_class='workflow-list', + empty_text='No workflows submitted yet', + auto_load='workflows/{sid}' + ) + ], + notifications=UINotifications( + topic='workflow_state', + id_field='wf_id', + state_field='state' + ) + ) # -------------------------------------------------------------------------- @@ -320,68 +513,21 @@ class PluginRose(Plugin): def __init__(self, app: FastAPI, instance_name: str = 'rose'): """ Initialize the ROSE plugin, registering all routes. - - Args: - app (FastAPI): The FastAPI application instance. - instance_name (str): Plugin namespace. Defaults to 'rose'. """ super().__init__(app, instance_name) self.add_route_post('submit/{sid}', self.submit_workflow) self.add_route_get ('status/{sid}/{wf_id}', self.get_workflow_status) - self.add_route_get ('workflows/{sid}', self.list_workflows) + self.add_route_get ('workflows/{sid}', self.list_workflows) self.add_route_post('cancel/{sid}/{wf_id}', self.cancel_workflow) - self.add_route_post('shutdown/{sid}', self.shutdown) self._log_routes() - # -------------------------------------------------------------------------- - # - async def register_session(self, request: Request) -> JSONResponse: - """ - Register a new ROSE session, binding it to a specific job ID. - - Overrides the base implementation to accept an optional ``job_id`` - from the request body (defaults to ``'local_job_0'``). - - Args: - request (Request): JSON body may contain ``{"job_id": "..."}`` - - Returns: - JSONResponse: ``{sid}`` — the assigned session ID. - """ - body = {} - try: - body = await request.json() - except Exception: - pass - - job_id = body.get('job_id', 'local_job_0') - - async with self._id_lock: - sid = f'session.{uuid.uuid4().hex[:8]}' - - self._sessions[sid] = self._create_session(sid, job_id=job_id) - log.info(f'[{self.instance_name}] Registered session {sid} ' - f'(job_id={job_id})') - - return JSONResponse({'sid': sid}) - - # -------------------------------------------------------------------------- # async def submit_workflow(self, request: Request) -> JSONResponse: - """ - Submit a workflow YAML file to the ROSE service. - - Args: - request (Request): Path param ``sid``. - JSON body: ``{"workflow_file": "/path/to/wf.yaml"}`` - - Returns: - JSONResponse: ``{req_id, wf_id}`` - """ + """Submit a workflow YAML file.""" sid = request.path_params['sid'] data = await request.json() @@ -392,15 +538,7 @@ async def submit_workflow(self, request: Request) -> JSONResponse: # -------------------------------------------------------------------------- # async def get_workflow_status(self, request: Request) -> JSONResponse: - """ - Return the status of a specific workflow. - - Args: - request (Request): Path params ``sid``, ``wf_id``. - - Returns: - JSONResponse: Workflow state dictionary. - """ + """Return the status of a specific workflow.""" sid = request.path_params['sid'] wf_id = request.path_params['wf_id'] @@ -411,15 +549,7 @@ async def get_workflow_status(self, request: Request) -> JSONResponse: # -------------------------------------------------------------------------- # async def list_workflows(self, request: Request) -> JSONResponse: - """ - List all workflows tracked by the ROSE service. - - Args: - request (Request): Path param ``sid``. - - Returns: - JSONResponse: Registry dict ``{wf_id → state dict}``. - """ + """List all workflows in the session.""" sid = request.path_params['sid'] return await self._forward(sid, RoseSession.list_workflows) @@ -428,15 +558,7 @@ async def list_workflows(self, request: Request) -> JSONResponse: # -------------------------------------------------------------------------- # async def cancel_workflow(self, request: Request) -> JSONResponse: - """ - Cancel a running workflow. - - Args: - request (Request): Path params ``sid``, ``wf_id``. - - Returns: - JSONResponse: ``{req_id, wf_id}`` - """ + """Cancel a running workflow.""" sid = request.path_params['sid'] wf_id = request.path_params['wf_id'] @@ -444,21 +566,4 @@ async def cancel_workflow(self, request: Request) -> JSONResponse: wf_id=wf_id) - # -------------------------------------------------------------------------- - # - async def shutdown(self, request: Request) -> JSONResponse: - """ - Send a graceful shutdown request to the ROSE service. - - Args: - request (Request): Path param ``sid``. - - Returns: - JSONResponse: ``{req_id}`` - """ - sid = request.path_params['sid'] - - return await self._forward(sid, RoseSession.shutdown) - - # ------------------------------------------------------------------------------ diff --git a/rose/service/manager.py b/rose/service/manager.py index df859481..b2d9f81e 100644 --- a/rose/service/manager.py +++ b/rose/service/manager.py @@ -8,8 +8,7 @@ from pathlib import Path from typing import Any, Dict, Optional, List, Callable -from radical.asyncflow import WorkflowEngine, ConcurrentExecutionBackend -from concurrent.futures import ProcessPoolExecutor +from radical.asyncflow import WorkflowEngine, LocalExecutionBackend from rose.al.active_learner import SequentialActiveLearner, ParallelActiveLearner from rose.learner import LearnerConfig, TaskConfig @@ -187,7 +186,7 @@ async def initialize(self): """Setup directories and backend.""" self.requests_dir.mkdir(parents=True, exist_ok=True) - backend = await ConcurrentExecutionBackend(ProcessPoolExecutor()) + backend = LocalExecutionBackend() self.engine = await WorkflowEngine.create(backend) logger.info(f"Service initialized at {self.service_root}") From 20f13e770a05ef70826f3ed0477da6d6a009e947 Mon Sep 17 00:00:00 2001 From: Andre Merzky Date: Wed, 4 Mar 2026 21:28:45 -0500 Subject: [PATCH 10/30] update plugin --- examples/service/README.md | 102 +++++- examples/service/debug_workflow.yaml | 39 +++ examples/service/example_rose_plugin.py | 137 ++++++++ rose/service/.claude/settings.local.json | 17 + rose/service/api/cli.py | 1 - rose/service/api/rest.py | 57 +++- rose/service/client.py | 2 +- rose/service/manager.py | 2 - tests/unit/test_rose_plugin.py | 379 +++++++++++++++++++++++ 9 files changed, 717 insertions(+), 19 deletions(-) create mode 100644 examples/service/debug_workflow.yaml create mode 100755 examples/service/example_rose_plugin.py create mode 100644 rose/service/.claude/settings.local.json create mode 100644 tests/unit/test_rose_plugin.py diff --git a/examples/service/README.md b/examples/service/README.md index 60c588f5..43703e21 100644 --- a/examples/service/README.md +++ b/examples/service/README.md @@ -10,8 +10,10 @@ The service uses file-based IPC: the manager polls a local directory for request |------|-------------| | `service_test.yaml` | Minimal test workflow using `/bin/echo` (no dependencies required) | | `service_real.yaml` | Real workflow using `ParallelActiveLearner` with Python scripts | +| `debug_workflow.yaml` | Fast test workflow for plugin testing (2 iterations) | | `run_service.py` | Integration example: launches, submits, monitors, and shuts down programmatically | | `verify_service.py` | Demonstrates workflow cancellation flow | +| `example_rose_plugin.py` | REST API example using RADICAL-Edge plugin | --- @@ -170,28 +172,100 @@ See [`verify_service.py`](verify_service.py) for an example of cancellation via --- -## Option 3 — REST API *(upcoming)* +## Option 3 — REST API via RADICAL-Edge Plugin -> **Not yet implemented.** A REST API for ROSE service is planned for a future release. +The ROSE plugin for RADICAL-Edge provides a REST API for workflow management. This is the recommended approach for remote access and integration with other services. -The REST API will expose the same operations as the CLI and Python client over HTTP, making it possible to submit and monitor workflows from any language or tool (e.g. `curl`, JavaScript, or remote machines). +**Architecture:** +``` +Client (Python/curl/browser) + ↓ HTTP/REST +RADICAL-Edge Bridge + ↓ WebSocket +Edge Service (with ROSE plugin) + ↓ +WorkflowEngine / Learners (embedded) +``` + +The plugin embeds workflow execution directly — no separate `rose launch` daemon required. + +### Prerequisites + +1. RADICAL-Edge bridge running +2. RADICAL-Edge service running with ROSE plugin loaded + +### REST Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/rose/register_session` | Register a new session | +| `POST` | `/rose/submit/{sid}` | Submit a workflow | +| `GET` | `/rose/status/{sid}/{wf_id}` | Get workflow status | +| `GET` | `/rose/workflows/{sid}` | List all workflows | +| `POST` | `/rose/cancel/{sid}/{wf_id}` | Cancel a workflow | +| `POST` | `/rose/unregister_session/{sid}` | Close session | + +### Python Client Example + +See [`example_rose_plugin.py`](example_rose_plugin.py) for a complete working example. + +```python +from radical.edge import BridgeClient +import rose.service.api.rest # Register plugin + +# Connect to bridge +bc = BridgeClient(url='https://localhost:8000') +edges = bc.list_edges() +ec = bc.get_edge_client(edges[0]) + +# Get ROSE plugin client +rose = ec.get_plugin('rose') + +# Submit workflow +result = rose.submit_workflow('/path/to/workflow.yaml') +wf_id = result['wf_id'] -Planned endpoints: +# Monitor status +status = rose.get_workflow_status(wf_id) +print(f"State: {status['state']}") +# List all workflows +workflows = rose.list_workflows() + +# Cancel if needed +rose.cancel_workflow(wf_id) + +# Cleanup +rose.close() +bc.close() ``` -POST /workflows Submit a new workflow -GET /workflows List all workflows -GET /workflows/{wf_id} Get status of a specific workflow -DELETE /workflows/{wf_id} Cancel a workflow -POST /shutdown Gracefully stop the service + +### Notifications + +The plugin sends real-time notifications via SSE when workflow state changes: + +```python +def on_state_change(topic, data): + print(f"Workflow {data['wf_id']}: {data['state']}") + +rose.on_workflow_state(on_state_change) ``` -When available, a workflow submission will look like: +### Running the Example ```bash -curl -X POST http://localhost:8080/workflows \ - -H "Content-Type: application/json" \ - -d '{"workflow_file": "/path/to/workflow.yaml"}' +# Set bridge URL +export RADICAL_BRIDGE_URL=https://localhost:8000 + +# Run example +python example_rose_plugin.py --workflow debug_workflow.yaml ``` -Stay tuned for updates. +--- + +## Additional Files + +| File | Description | +|------|-------------| +| `example_rose_plugin.py` | REST API example using RADICAL-Edge plugin | +| `debug_workflow.yaml` | Fast test workflow (2 iterations, ~6 seconds) | diff --git a/examples/service/debug_workflow.yaml b/examples/service/debug_workflow.yaml new file mode 100644 index 00000000..151a9055 --- /dev/null +++ b/examples/service/debug_workflow.yaml @@ -0,0 +1,39 @@ +# Debug Workflow for ROSE Plugin Testing +# +# This workflow uses simple echo commands to quickly test +# the ROSE service and plugin without heavy computation. +# +# Usage: +# python example_rose_client.py --workflow debug_workflow.yaml + +learner: + type: SequentialActiveLearner + +components: + simulation: + type: script + path: /bin/bash + config: + args: + - "-c" + - "echo '[SIM] Iteration $ROSE_ITERATION' && sleep 1" + + training: + type: script + path: /bin/bash + config: + args: + - "-c" + - "echo '[TRAIN] Training model...' && sleep 1" + + active_learn: + type: script + path: /bin/bash + config: + args: + - "-c" + - "echo '[AL] Active learning step' && sleep 1" + +config: + max_iterations: 2 + work_dir: /tmp/rose_debug diff --git a/examples/service/example_rose_plugin.py b/examples/service/example_rose_plugin.py new file mode 100755 index 00000000..5d381dcf --- /dev/null +++ b/examples/service/example_rose_plugin.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" +Example: Test ROSE Edge Plugin + +Connects to a running RADICAL-Edge bridge, submits a workflow via the +ROSE plugin, and monitors its status. + +Prerequisites: + 1. Bridge running: radical-edge-bridge + 2. Edge with ROSE plugin: radical-edge-service (with ROSE plugin loaded) + 3. ROSE ServiceManager: rose launch --job-id local_job_0 + +Usage: + export RADICAL_BRIDGE_URL=https://localhost:8443 + python example_rose_plugin.py [--workflow FILE] [--job-id ID] +""" + +import os +import sys +import time +import logging +import argparse +from pathlib import Path + +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s', + datefmt='%H:%M:%S' +) +for name in ['httpx', 'httpcore', 'urllib3']: + logging.getLogger(name).setLevel(logging.DEBUG) + +log = logging.getLogger('rose.example') + + +def notification_cb(topic: str, data: dict): + """Handle workflow notifications.""" + log.info(f'[NOTIFY] {topic}: {data}') + + +def main(): + parser = argparse.ArgumentParser(description='Test ROSE Edge Plugin') + parser.add_argument('--workflow', '-w', default='debug_workflow.yaml') + parser.add_argument('--job-id', '-j', default='local_job_0') + parser.add_argument('--bridge-url', '-b', + default=os.environ.get('RADICAL_BRIDGE_URL', + 'https://localhost:8443')) + args = parser.parse_args() + + # Resolve workflow path + workflow = Path(args.workflow) + if not workflow.exists(): + workflow = Path(__file__).parent / args.workflow + if not workflow.exists(): + log.error(f'Workflow not found: {args.workflow}') + sys.exit(1) + + log.info(f'Bridge: {args.bridge_url}') + log.info(f'Workflow: {workflow}') + log.info(f'Job ID: {args.job_id}') + log.info('-' * 60) + + from radical.edge import BridgeClient + + # Import ROSE plugin to register client class locally + import rose.service.api.rest # noqa: F401 + + try: + bc = BridgeClient(url=args.bridge_url) + edges = bc.list_edges() + except Exception as e: + log.error(f'Cannot connect to bridge: {e}') + log.error('Make sure bridge and edge service are running.') + sys.exit(1) + + if not edges: + log.error('No edges connected to bridge') + sys.exit(1) + + edge_id = edges[0] + log.info(f'Using edge: {edge_id}') + + try: + ec = bc.get_edge_client(edge_id) + rose = ec.get_plugin('rose', job_id=args.job_id) + except Exception as e: + log.error(f'Cannot get ROSE plugin: {e}') + log.error('Make sure ROSE plugin is loaded on edge service.') + sys.exit(1) + + log.info(f'Session: {rose.sid}') + rose.on_workflow_state(notification_cb) + + try: + # Submit workflow + log.info(f'Submitting {workflow}...') + result = rose.submit_workflow(str(workflow.absolute())) + wf_id = result['wf_id'] + log.info(f'Submitted: {wf_id}') + + # Monitor status + log.info('Monitoring (Ctrl+C to cancel)...') + terminal = {'COMPLETED', 'FAILED', 'CANCELED'} + last_state = None + + for i in range(120): + time.sleep(2) + try: + status = rose.get_workflow_status(wf_id) + state = status.get('state', 'UNKNOWN') + if state != last_state: + log.info(f'State: {state}') + last_state = state + if state in terminal: + if state == 'FAILED': + log.error(f'Error: {status.get("error")}') + break + except Exception as e: + log.warning(f'Status error: {e}') + + # Final state + log.info('-' * 60) + for wid, info in rose.list_workflows().items(): + log.info(f'{wid}: {info.get("state")}') + + except KeyboardInterrupt: + log.warning('Interrupted') + + finally: + rose.off_workflow_state(notification_cb) + rose.close() + bc.close() + log.info('Done.') + + +if __name__ == '__main__': + main() diff --git a/rose/service/.claude/settings.local.json b/rose/service/.claude/settings.local.json new file mode 100644 index 00000000..4742ca3e --- /dev/null +++ b/rose/service/.claude/settings.local.json @@ -0,0 +1,17 @@ +{ + "permissions": { + "allow": [ + "Bash(wc:*)", + "Bash(python -c:*)", + "Bash(source:*)", + "Bash(ruff check:*)", + "Bash(python3:*)", + "Bash(pip show:*)", + "Bash(pkill:*)", + "Bash(pgrep:*)", + "Bash(find:*)", + "Bash(xargs:*)", + "Bash(grep:*)" + ] + } +} diff --git a/rose/service/api/cli.py b/rose/service/api/cli.py index 5fe32d67..1f5c6b82 100644 --- a/rose/service/api/cli.py +++ b/rose/service/api/cli.py @@ -4,7 +4,6 @@ import sys import json import logging -from pathlib import Path from rose.service.manager import ServiceManager from rose.service.client import ServiceClient diff --git a/rose/service/api/rest.py b/rose/service/api/rest.py index 3bc29952..fdd9ff18 100644 --- a/rose/service/api/rest.py +++ b/rose/service/api/rest.py @@ -1,3 +1,59 @@ +""" +ROSE Plugin for RADICAL-Edge +============================= + +This module provides a RADICAL-Edge plugin for ROSE (Remote Online Smart +Experiment) workflow management. It enables submission, monitoring, and +cancellation of Active Learning workflows through REST endpoints. + +Architecture +------------ +The plugin embeds workflow execution directly within the Edge service, +eliminating the need for a separate ServiceManager daemon. Each RoseSession +maintains its own WorkflowEngine and executes learner loops as async tasks. + +:: + + Client (Python/curl/browser) + ↓ HTTP/REST + RADICAL-Edge Bridge + ↓ WebSocket + Edge Service (with ROSE plugin) + ↓ + WorkflowEngine / Learners (embedded) + +Components +---------- +- **PluginRose**: The plugin class registered with RADICAL-Edge. Defines REST + routes and UI configuration for portal integration. + +- **RoseSession**: Server-side session managing workflow execution. Each + session lazily initializes a WorkflowEngine and tracks all submitted + workflows. + +- **RoseClient**: Application-side client providing synchronous methods for + workflow operations. + +REST Endpoints +-------------- +- ``POST /rose/register_session`` - Create a new session +- ``POST /rose/submit/{sid}`` - Submit a workflow YAML +- ``GET /rose/status/{sid}/{wf_id}`` - Get workflow status +- ``GET /rose/workflows/{sid}`` - List all workflows +- ``POST /rose/cancel/{sid}/{wf_id}`` - Cancel a workflow +- ``POST /rose/unregister_session/{sid}`` - Close session + +Notifications +------------- +The plugin sends real-time notifications via SSE when workflow state changes. +Clients can subscribe using ``RoseClient.on_workflow_state(callback)``. + +See Also +-------- +- ``rose.service.manager.WorkflowLoader`` - YAML parsing and learner creation +- ``rose.al.active_learner`` - SequentialActiveLearner, ParallelActiveLearner +- ``radical.edge.plugin_base.Plugin`` - Base plugin class +""" __author__ = 'RADICAL Development Team' __email__ = 'radical@radical-project.org' @@ -24,7 +80,6 @@ from radical.asyncflow import WorkflowEngine, LocalExecutionBackend from rose.al.active_learner import SequentialActiveLearner, ParallelActiveLearner -from rose.learner import LearnerConfig, TaskConfig from rose.service.models import Workflow, WorkflowState from rose.service.manager import WorkflowLoader diff --git a/rose/service/client.py b/rose/service/client.py index 98913adc..57b2017a 100644 --- a/rose/service/client.py +++ b/rose/service/client.py @@ -3,7 +3,7 @@ import time import logging from pathlib import Path -from typing import Any, Dict, Optional, List +from typing import Any, Dict, Optional logger = logging.getLogger(__name__) diff --git a/rose/service/manager.py b/rose/service/manager.py index b2d9f81e..a3cf6022 100644 --- a/rose/service/manager.py +++ b/rose/service/manager.py @@ -1,9 +1,7 @@ import asyncio import json import os -import shutil import importlib -import sys import logging from pathlib import Path from typing import Any, Dict, Optional, List, Callable diff --git a/tests/unit/test_rose_plugin.py b/tests/unit/test_rose_plugin.py new file mode 100644 index 00000000..0329accf --- /dev/null +++ b/tests/unit/test_rose_plugin.py @@ -0,0 +1,379 @@ +""" +Unit tests for the ROSE Edge Plugin. + +Tests the RoseSession, RoseClient, and WorkflowLoader classes. +""" + +import pytest +import asyncio +from unittest.mock import Mock, AsyncMock, patch, MagicMock +from pathlib import Path + +from rose.service.api.rest import RoseSession, RoseClient, PluginRose +from rose.service.models import Workflow, WorkflowState +from rose.service.manager import WorkflowLoader + + +# ----------------------------------------------------------------------------- +# Fixtures +# ----------------------------------------------------------------------------- + +@pytest.fixture +def mock_engine(): + """Create a mock WorkflowEngine.""" + engine = AsyncMock() + engine.shutdown = AsyncMock() + return engine + + +@pytest.fixture +def rose_session(): + """Create a RoseSession for testing.""" + session = RoseSession(sid='test-session-001') + return session + + +@pytest.fixture +def sample_workflow_yaml(tmp_path): + """Create a sample workflow YAML file.""" + wf_content = """ +learner: + type: SequentialActiveLearner + +components: + simulation: + type: script + path: /bin/echo + config: + args: ["sim"] + training: + type: script + path: /bin/echo + config: + args: ["train"] + active_learn: + type: script + path: /bin/echo + config: + args: ["al"] + +config: + max_iterations: 2 + work_dir: /tmp/rose_test +""" + wf_file = tmp_path / "test_workflow.yaml" + wf_file.write_text(wf_content) + return str(wf_file) + + +# ----------------------------------------------------------------------------- +# WorkflowLoader Tests +# ----------------------------------------------------------------------------- + +class TestWorkflowLoader: + """Tests for WorkflowLoader class.""" + + def test_load_yaml_valid(self, sample_workflow_yaml): + """Test loading a valid YAML workflow file.""" + wf_def = WorkflowLoader.load_yaml(sample_workflow_yaml) + + assert 'learner' in wf_def + assert wf_def['learner']['type'] == 'SequentialActiveLearner' + assert 'components' in wf_def + assert 'simulation' in wf_def['components'] + assert 'config' in wf_def + assert wf_def['config']['max_iterations'] == 2 + + def test_load_yaml_file_not_found(self): + """Test loading a non-existent file raises error.""" + with pytest.raises(FileNotFoundError): + WorkflowLoader.load_yaml('/nonexistent/path/workflow.yaml') + + def test_create_learner_sequential(self, sample_workflow_yaml, mock_engine): + """Test creating a SequentialActiveLearner from YAML.""" + wf_def = WorkflowLoader.load_yaml(sample_workflow_yaml) + learner, config = WorkflowLoader.create_learner( + 'wf.test001', wf_def, mock_engine + ) + + from rose.al.active_learner import SequentialActiveLearner + assert isinstance(learner, SequentialActiveLearner) + assert learner.learner_id == hash('wf.test001') + + def test_import_function_valid(self): + """Test importing a valid function path.""" + func = WorkflowLoader._import_function('os.path.exists') + import os + assert func == os.path.exists + + def test_import_function_invalid(self): + """Test importing an invalid function path raises error.""" + with pytest.raises(ImportError): + WorkflowLoader._import_function('nonexistent.module.func') + + def test_create_script_task_factory(self): + """Test creating a script task factory.""" + factory = WorkflowLoader._create_script_task_factory('/bin/echo') + + # The factory should be an async function + assert asyncio.iscoroutinefunction(factory) + + +# ----------------------------------------------------------------------------- +# RoseSession Tests +# ----------------------------------------------------------------------------- + +class TestRoseSession: + """Tests for RoseSession class.""" + + def test_init(self, rose_session): + """Test RoseSession initialization.""" + assert rose_session.sid == 'test-session-001' + assert rose_session.is_active + assert rose_session._workflows == {} + assert rose_session._engine is None + + @pytest.mark.asyncio + async def test_ensure_engine(self, rose_session): + """Test lazy engine initialization.""" + with patch('rose.service.api.rest.LocalExecutionBackend') as mock_backend, \ + patch('rose.service.api.rest.WorkflowEngine') as mock_engine_cls: + + mock_backend.return_value = Mock() + mock_engine_cls.create = AsyncMock(return_value=Mock()) + + await rose_session._ensure_engine() + + assert rose_session._engine is not None + mock_engine_cls.create.assert_called_once() + + @pytest.mark.asyncio + async def test_submit_workflow(self, rose_session, sample_workflow_yaml): + """Test workflow submission.""" + # Mock the engine and workflow execution + with patch.object(rose_session, '_ensure_engine', new_callable=AsyncMock), \ + patch.object(rose_session, '_run_workflow', new_callable=AsyncMock): + + rose_session._engine = Mock() + + result = await rose_session.submit_workflow(sample_workflow_yaml) + + assert 'wf_id' in result + assert result['wf_id'].startswith('wf.') + assert result['wf_id'] in rose_session._workflows + assert rose_session._workflows[result['wf_id']].state == WorkflowState.SUBMITTED + + @pytest.mark.asyncio + async def test_get_workflow_status_found(self, rose_session): + """Test getting status of an existing workflow.""" + # Add a workflow manually + wf = Workflow(wf_id='wf.test123', state=WorkflowState.RUNNING) + rose_session._workflows['wf.test123'] = wf + + status = await rose_session.get_workflow_status('wf.test123') + + assert status['wf_id'] == 'wf.test123' + assert status['state'] == 'RUNNING' + + @pytest.mark.asyncio + async def test_get_workflow_status_not_found(self, rose_session): + """Test getting status of non-existent workflow raises 404.""" + from fastapi import HTTPException + + with pytest.raises(HTTPException) as exc_info: + await rose_session.get_workflow_status('wf.nonexistent') + + assert exc_info.value.status_code == 404 + + @pytest.mark.asyncio + async def test_list_workflows(self, rose_session): + """Test listing all workflows.""" + # Add some workflows + rose_session._workflows['wf.001'] = Workflow( + wf_id='wf.001', state=WorkflowState.COMPLETED + ) + rose_session._workflows['wf.002'] = Workflow( + wf_id='wf.002', state=WorkflowState.RUNNING + ) + + result = await rose_session.list_workflows() + + assert len(result) == 2 + assert 'wf.001' in result + assert 'wf.002' in result + assert result['wf.001']['state'] == 'COMPLETED' + assert result['wf.002']['state'] == 'RUNNING' + + @pytest.mark.asyncio + async def test_cancel_workflow(self, rose_session): + """Test canceling a running workflow.""" + # Add a running workflow with mock learner + mock_learner = Mock() + mock_task = AsyncMock() + mock_task.done.return_value = False + + wf = Workflow(wf_id='wf.cancel', state=WorkflowState.RUNNING) + wf.learner_instance = mock_learner + rose_session._workflows['wf.cancel'] = wf + rose_session._learner_tasks['wf.cancel'] = mock_task + + result = await rose_session.cancel_workflow('wf.cancel') + + assert result['wf_id'] == 'wf.cancel' + mock_learner.stop.assert_called_once() + mock_task.cancel.assert_called_once() + + @pytest.mark.asyncio + async def test_cancel_workflow_not_running(self, rose_session): + """Test canceling a completed workflow raises 400.""" + from fastapi import HTTPException + + wf = Workflow(wf_id='wf.done', state=WorkflowState.COMPLETED) + rose_session._workflows['wf.done'] = wf + + with pytest.raises(HTTPException) as exc_info: + await rose_session.cancel_workflow('wf.done') + + assert exc_info.value.status_code == 400 + + @pytest.mark.asyncio + async def test_close_session(self, rose_session): + """Test closing session stops all workflows.""" + # Add a running workflow + mock_learner = Mock() + mock_task = AsyncMock() + mock_task.done.return_value = False + + wf = Workflow(wf_id='wf.close', state=WorkflowState.RUNNING) + wf.learner_instance = mock_learner + rose_session._workflows['wf.close'] = wf + rose_session._learner_tasks['wf.close'] = mock_task + + rose_session._engine = AsyncMock() + rose_session._engine.shutdown = AsyncMock() + + result = await rose_session.close() + + assert result == {} + assert not rose_session.is_active + mock_learner.stop.assert_called_once() + rose_session._engine.shutdown.assert_called_once() + + @pytest.mark.asyncio + async def test_session_closed_check(self, rose_session): + """Test operations on closed session raise error.""" + await rose_session.close() + + with pytest.raises(RuntimeError, match="session is closed"): + await rose_session.list_workflows() + + +# ----------------------------------------------------------------------------- +# RoseClient Tests +# ----------------------------------------------------------------------------- + +class TestRoseClient: + """Tests for RoseClient class.""" + + @pytest.fixture + def mock_http(self): + """Create a mock HTTP client.""" + http = Mock() + response = Mock() + response.json.return_value = {'sid': 'session.abc123'} + response.raise_for_status = Mock() + http.post.return_value = response + http.get.return_value = response + return http + + @pytest.fixture + def rose_client(self, mock_http): + """Create a RoseClient with mocked HTTP.""" + client = RoseClient(mock_http, '/test/rose') + client._sid = 'session.test' + return client + + def test_submit_workflow(self, rose_client, mock_http): + """Test submitting a workflow via client.""" + mock_http.post.return_value.json.return_value = {'wf_id': 'wf.new'} + + result = rose_client.submit_workflow('/path/to/wf.yaml') + + assert result == {'wf_id': 'wf.new'} + mock_http.post.assert_called() + + def test_submit_workflow_no_session(self, mock_http): + """Test submit without session raises error.""" + client = RoseClient(mock_http, '/test/rose') + # No session registered + + with pytest.raises(RuntimeError, match="No active session"): + client.submit_workflow('/path/to/wf.yaml') + + def test_get_workflow_status(self, rose_client, mock_http): + """Test getting workflow status via client.""" + mock_http.get.return_value.json.return_value = { + 'wf_id': 'wf.123', + 'state': 'RUNNING' + } + + result = rose_client.get_workflow_status('wf.123') + + assert result['wf_id'] == 'wf.123' + assert result['state'] == 'RUNNING' + + def test_list_workflows(self, rose_client, mock_http): + """Test listing workflows via client.""" + mock_http.get.return_value.json.return_value = { + 'wf.001': {'state': 'COMPLETED'}, + 'wf.002': {'state': 'RUNNING'} + } + + result = rose_client.list_workflows() + + assert len(result) == 2 + + def test_cancel_workflow(self, rose_client, mock_http): + """Test canceling workflow via client.""" + mock_http.post.return_value.json.return_value = {'wf_id': 'wf.cancel'} + + result = rose_client.cancel_workflow('wf.cancel') + + assert result['wf_id'] == 'wf.cancel' + + def test_notification_callbacks(self, rose_client): + """Test registering notification callbacks.""" + callback = Mock() + + # Should not raise + with patch.object(rose_client, 'register_notification_callback'): + rose_client.on_workflow_state(callback) + rose_client.register_notification_callback.assert_called_with(callback) + + with patch.object(rose_client, 'unregister_notification_callback'): + rose_client.off_workflow_state(callback) + rose_client.unregister_notification_callback.assert_called_with(callback) + + +# ----------------------------------------------------------------------------- +# PluginRose Tests +# ----------------------------------------------------------------------------- + +class TestPluginRose: + """Tests for PluginRose class.""" + + def test_plugin_attributes(self): + """Test plugin class attributes.""" + assert PluginRose.plugin_name == 'rose' + assert PluginRose.session_class == RoseSession + assert PluginRose.client_class == RoseClient + assert PluginRose.version == '0.2.0' + assert PluginRose.session_ttl == 0 + + def test_ui_config(self): + """Test UI configuration is defined.""" + assert PluginRose.ui_config is not None + assert PluginRose.ui_config.title == 'ROSE Active Learning' + assert len(PluginRose.ui_config.forms) == 1 + assert len(PluginRose.ui_config.monitors) == 1 + assert PluginRose.ui_config.notifications is not None From 13c25cdd5b54f36fcc2ebead8a9e173dce002cdb Mon Sep 17 00:00:00 2001 From: AymenFJA Date: Mon, 16 Mar 2026 16:22:15 +0100 Subject: [PATCH 11/30] merging main changes into raas --- .github/workflows/ci.yml | 6 +- .github/workflows/tests.yml | 39 +- .gitignore | 3 +- .pre-commit-config.yaml | 54 ++ CHANGELOG.md | 44 +- README.md | 4 +- docs/changelog.md | 2 +- docs/getting-started/dry-run.md | 2 +- docs/getting-started/installation.md | 2 +- docs/index.md | 6 +- docs/integrations/clearml.md | 125 ++++ docs/integrations/mlflow.md | 192 +++--- docs/styles/custom.css | 4 +- docs/user-guide/acl-metrics.md | 2 +- docs/user-guide/advanced-acl-workflow.md | 8 +- docs/user-guide/advanced-rl-workflow.md | 42 +- docs/user-guide/basic-acl-workflow.md | 2 +- docs/user-guide/basic-rl-workflow.md | 2 +- docs/user-guide/experience.md | 1 - docs/user-guide/parallel_learners_docs.md | 73 ++- docs/user-guide/target-resources.md | 10 +- docs/user-guide/tracking.md | 409 +++++++++++++ docs/user-guide/uq_based-acl-workflow.md | 20 +- .../active_learn/advanced/active_learn.py | 24 +- .../advanced/advanced-tutorial.ipynb | 299 ---------- .../active_learn/advanced/check_accuracy.py | 10 +- examples/active_learn/advanced/run_me.py | 30 +- examples/active_learn/advanced/simulation.py | 27 +- examples/active_learn/advanced/training.py | 16 +- .../algorithm_selector/active_1.py | 27 +- .../algorithm_selector/active_2.py | 27 +- .../algorithm_selector/check_mse.py | 24 +- .../active_learn/algorithm_selector/run_me.py | 34 +- .../active_learn/algorithm_selector/sim.py | 29 +- .../active_learn/algorithm_selector/train.py | 33 +- examples/active_learn/basic/active.py | 24 +- .../active_learn/basic/basic-tutorial.ipynb | 314 ---------- examples/active_learn/basic/check_mse.py | 8 +- examples/active_learn/basic/run_me.py | 24 +- examples/active_learn/basic/sim.py | 8 +- examples/active_learn/basic/train.py | 17 +- examples/active_learn/parallel/active.py | 39 +- examples/active_learn/parallel/check_mse.py | 18 +- .../parallel/run_me_per_learner_config.py | 46 +- .../run_me_per_learner_per_iter_config.py | 33 +- .../parallel/run_me_with_dynamic_config.py | 39 +- examples/active_learn/parallel/sim.py | 46 +- examples/active_learn/parallel/train.py | 17 +- examples/active_learn/state_control.py | 47 +- .../uq}/active_learn.py | 31 +- .../uq}/check_accuracy.py | 50 +- .../uq}/check_uq.py | 47 +- .../uq}/models.py | 24 +- .../uq}/predict.py | 74 ++- examples/active_learn/uq/run_me.py | 196 +++++++ examples/active_learn/uq/simulation.py | 59 ++ .../uq}/training.py | 67 ++- examples/active_learn/uq_based_al/active.py | 55 -- .../uq_based_al/basic-tutorial.ipynb | 314 ---------- .../active_learn/uq_based_al/check_mse.py | 25 - examples/active_learn/uq_based_al/run_me.py | 47 -- examples/active_learn/uq_based_al/sim.py | 25 - examples/active_learn/uq_based_al/train.py | 29 - examples/integrations/mlflow/README.md | 278 --------- examples/integrations/mlflow/mlflow_rose.py | 546 ------------------ examples/integrations/tracking/README.md | 278 +++++++++ examples/integrations/tracking/basic.py | 325 +++++++++++ .../integrations/tracking/clearml/run_me.py | 288 +++++++++ .../tracking/mlflow/run_me_tracker.py | 288 +++++++++ examples/reinforcement_learn/check_reward.py | 11 +- examples/reinforcement_learn/environment.py | 28 +- examples/reinforcement_learn/merge.py | 23 +- examples/reinforcement_learn/model.py | 4 +- .../mujoco/mujocolearning.ipynb | 11 +- examples/reinforcement_learn/mujoco/policy.py | 7 +- .../reinforcement_learn/mujoco/simulate.py | 36 +- .../reinforcement_learn/mujoco/test_model.py | 37 +- examples/reinforcement_learn/mujoco/train.py | 22 +- .../parallelexperience.ipynb | 11 +- .../reinforcementlearning.ipynb | 11 +- examples/reinforcement_learn/run_me.py | 31 +- examples/reinforcement_learn/update.py | 24 +- examples/uq_active_learn/run_me.py | 188 ------ examples/uq_active_learn/simulation.py | 47 -- .../use_cases/neutron-scattering/run_me.py | 145 +++-- .../scripts/active_learning.py | 60 +- .../scripts/compute_kernel.py | 152 ++--- .../scripts/merge_preprocess_hdf5.py | 61 +- .../neutron-scattering/scripts/model.py | 40 +- .../scripts/prepare_data_dir_pm.py | 41 +- .../scripts/preprocess_study.py | 37 +- .../scripts/replacement_sim.py | 9 +- .../scripts/simulation_resample.py | 368 +++++++----- .../scripts/simulation_resample_prepare.py | 228 +++++--- .../scripts/simulation_resample_real_work.py | 191 +++--- .../scripts/simulation_sample.py | 299 ++++++---- .../scripts/simulation_sweep.py | 315 ++++++---- .../neutron-scattering/scripts/sweep_utils.py | 108 ++-- .../neutron-scattering/scripts/train.py | 466 ++++++++++----- .../neutron-scattering/scripts/util.py | 36 +- mkdocs.yml | 4 +- pyproject.toml | 39 +- rose/__init__.py | 4 + rose/al/active_learner.py | 376 ++++++------ rose/al/selector.py | 151 ++--- rose/integrations/__init__.py | 0 rose/integrations/clearml_tracker.py | 157 +++++ rose/integrations/mlflow_tracker.py | 115 ++++ rose/learner.py | 238 ++++++-- rose/rl/experience.py | 23 +- rose/rl/reinforcement_learner.py | 415 +++++++------ rose/tracking.py | 175 ++++++ rose/uq/uq_active_learner.py | 520 ++++++++--------- rose/uq/uq_learner.py | 34 +- rose/uq/uq_scorer.py | 25 +- .../integration/test_run_parallel_learner.py | 16 +- tests/integration/test_run_rl_par_learner.py | 14 +- tests/integration/test_run_rl_seq_learner.py | 11 +- .../test_run_sequential_learner.py | 7 +- tests/integration/test_run_uq_learner.py | 24 +- .../tracking/test_clearml_tracker.py | 405 +++++++++++++ .../tracking/test_mlflow_tracker.py | 343 +++++++++++ tests/unit/test_learner_core.py | 459 +++++++++++++++ tests/unit/test_learner_stop.py | 43 +- tests/unit/test_parallel_learner.py | 89 ++- tests/unit/test_rl_par_learner.py | 82 ++- tests/unit/test_rl_seq_learner.py | 8 +- tests/unit/test_sequential_learner.py | 42 +- tests/unit/test_uq_learner.py | 156 ++--- tests/unit/tracking/test_tracker_core.py | 82 +++ tests/unit/tracking/test_tracker_interface.py | 398 +++++++++++++ tox.ini | 10 +- .../00-active-learning.ipynb | 33 +- .../01-reinforcement-learning.ipynb | 29 +- .../03-highly-parallel-surrogates.ipynb | 43 +- .../04-al-algorithm-selector.ipynb | 11 +- {examples/tutorials => tutorials}/README.md | 10 +- .../tutorials => tutorials}/pyproject.toml | 12 +- 138 files changed, 7871 insertions(+), 5137 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 docs/integrations/clearml.md create mode 100644 docs/user-guide/tracking.md delete mode 100644 examples/active_learn/advanced/advanced-tutorial.ipynb delete mode 100644 examples/active_learn/basic/basic-tutorial.ipynb rename examples/{uq_active_learn => active_learn/uq}/active_learn.py (69%) rename examples/{uq_active_learn => active_learn/uq}/check_accuracy.py (61%) rename examples/{uq_active_learn => active_learn/uq}/check_uq.py (59%) rename examples/{uq_active_learn => active_learn/uq}/models.py (85%) rename examples/{uq_active_learn => active_learn/uq}/predict.py (58%) create mode 100644 examples/active_learn/uq/run_me.py create mode 100644 examples/active_learn/uq/simulation.py rename examples/{uq_active_learn => active_learn/uq}/training.py (66%) delete mode 100644 examples/active_learn/uq_based_al/active.py delete mode 100644 examples/active_learn/uq_based_al/basic-tutorial.ipynb delete mode 100644 examples/active_learn/uq_based_al/check_mse.py delete mode 100644 examples/active_learn/uq_based_al/run_me.py delete mode 100644 examples/active_learn/uq_based_al/sim.py delete mode 100644 examples/active_learn/uq_based_al/train.py delete mode 100644 examples/integrations/mlflow/README.md delete mode 100644 examples/integrations/mlflow/mlflow_rose.py create mode 100644 examples/integrations/tracking/README.md create mode 100644 examples/integrations/tracking/basic.py create mode 100644 examples/integrations/tracking/clearml/run_me.py create mode 100644 examples/integrations/tracking/mlflow/run_me_tracker.py delete mode 100644 examples/uq_active_learn/run_me.py delete mode 100644 examples/uq_active_learn/simulation.py create mode 100644 rose/integrations/__init__.py create mode 100644 rose/integrations/clearml_tracker.py create mode 100644 rose/integrations/mlflow_tracker.py create mode 100644 rose/tracking.py create mode 100644 tests/integration/tracking/test_clearml_tracker.py create mode 100644 tests/integration/tracking/test_mlflow_tracker.py create mode 100644 tests/unit/test_learner_core.py create mode 100644 tests/unit/tracking/test_tracker_core.py create mode 100644 tests/unit/tracking/test_tracker_interface.py rename {examples/tutorials => tutorials}/00-active-learning.ipynb (98%) rename {examples/tutorials => tutorials}/01-reinforcement-learning.ipynb (97%) rename examples/active_learn/highly_parallel_surrogates_on_hpc.ipynb => tutorials/03-highly-parallel-surrogates.ipynb (98%) rename examples/active_learn/algorithm_selector/algorithm-selector-tutorial.ipynb => tutorials/04-al-algorithm-selector.ipynb (99%) rename {examples/tutorials => tutorials}/README.md (73%) rename {examples/tutorials => tutorials}/pyproject.toml (83%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 530ba606..cdd53dc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: ci +name: ci on: push: branches: @@ -18,7 +18,7 @@ jobs: - uses: actions/setup-python@v5 with: python-version: 3.x - - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV + - run: echo "cache_id=$(date --utc '+%V')" >> "$GITHUB_ENV" - uses: actions/cache@v4 with: key: mkdocs-material-${{ env.cache_id }} @@ -26,4 +26,4 @@ jobs: restore-keys: | mkdocs-material- - run: pip install mkdocs mkdocs-material mkdocs-glightbox mkdocs-material-extensions mkdocs-minify-plugin - - run: mkdocs gh-deploy --force \ No newline at end of file + - run: mkdocs gh-deploy --force diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f9e6856d..c6f0acc7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,20 +3,29 @@ name: tests on: push: branches: [main, test-me-*] - tags: pull_request: workflow_dispatch: jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Run pre-commit + uses: pre-commit/action@v3.0.1 + unit: + needs: [pre-commit] timeout-minutes: 15 strategy: fail-fast: false matrix: include: - - os: ubuntu-latest - python: '3.9' - toxenv: py39 - os: ubuntu-latest python: '3.10' toxenv: py310 @@ -39,7 +48,7 @@ jobs: python-version: ${{ matrix.python }} - name: Get pip cache dir id: pip-cache-dir - run: echo "PIP_CACHE_DIR=$(pip cache dir)" >> $GITHUB_ENV + run: echo "PIP_CACHE_DIR=$(pip cache dir)" >> "$GITHUB_ENV" - name: Use pip cache id: pip-cache uses: actions/cache@v4 @@ -54,14 +63,12 @@ jobs: run: tox -e ${{ matrix.toxenv }} -- -vv integration: + needs: [pre-commit] timeout-minutes: 20 strategy: fail-fast: false matrix: include: - - os: ubuntu-latest - python: '3.9' - toxenv: py39-all - os: ubuntu-latest python: '3.10' toxenv: py310-all @@ -84,7 +91,7 @@ jobs: python-version: ${{ matrix.python }} - name: Get pip cache dir id: pip-cache-dir - run: echo "PIP_CACHE_DIR=$(pip cache dir)" >> $GITHUB_ENV + run: echo "PIP_CACHE_DIR=$(pip cache dir)" >> "$GITHUB_ENV" - name: Use pip cache id: pip-cache uses: actions/cache@v4 @@ -97,17 +104,3 @@ jobs: run: python -m pip install --upgrade pip tox - name: Run Integration Tests run: tox -e ${{ matrix.toxenv }} -- -vv - - lint: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - name: Install tox - run: python -m pip install --upgrade pip tox - - name: Run linting - run: tox -e lint diff --git a/.gitignore b/.gitignore index 0cd7cd35..e4934849 100644 --- a/.gitignore +++ b/.gitignore @@ -159,5 +159,6 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. # rose specific +*.db *.pkl -asyncflow.session.* \ No newline at end of file +asyncflow.session.* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..68572c83 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,54 @@ +# .pre-commit-config.yaml + +default_language_version: + python: python3.12 + +repos: + - repo: https://github.com/PyCQA/docformatter + rev: v1.7.7 + hooks: + - id: docformatter + args: ["--in-place", "--wrap-summaries=100", "--wrap-descriptions=100"] + language_version: python3.12 + exclude: ^examples/use_cases/ + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.13.1 + hooks: + - id: ruff + args: ["--fix", "--exit-non-zero-on-fix"] + language_version: python3.12 + exclude: \.ipynb$|^examples/use_cases/ + - id: ruff-format + language_version: python3.12 + exclude: \.ipynb$|^examples/use_cases/ + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + exclude: ^mkdocs\.yml$ + - id: check-toml + - id: debug-statements + - id: check-merge-conflict + - id: check-added-large-files + args: ["--maxkb=500"] + + - repo: https://github.com/rhysd/actionlint + rev: v1.7.7 + hooks: + - id: actionlint + + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.34.0 + hooks: + - id: check-github-workflows + files: ^\.github/workflows/.*\.ya?ml$ + + - repo: https://github.com/crate-ci/typos + rev: v1.36.2 + hooks: + - id: typos + exclude: ^examples/use_cases/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cc59b73..850c4e79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,15 +8,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +--- + +## [0.3.0] - 2026-03-05 + +### Added +- **`IterationState.learner_id`**: New field (`int | str | None`, default `None`) on `IterationState` + identifying which parallel learner produced a given state. Integer index for + `ParallelActiveLearner` and `ParallelReinforcementLearner`; learner name string for + `ParallelUQLearner`. + +### Changed +- **Unified async-iterator API for parallel learners**: `ParallelActiveLearner.start()`, + `ParallelReinforcementLearner.start()`, and `ParallelUQLearner.start()` now return + `AsyncIterator[IterationState]` instead of blocking until all learners finish and returning + `list[Any]`. States stream in real time as each parallel learner completes an iteration, + using the same `async for state in learner.start():` interface as `SequentialActiveLearner`. +- **Shared `_stream_parallel` helper**: The internal `asyncio.Queue`-based fan-in pattern + is extracted into a single module-level async generator in `rose/learner.py`, eliminating + identical code that was previously duplicated across all three parallel learner classes. + +### Deprecated +- **`ParallelActiveLearner.teach()`**, **`ParallelReinforcementLearner.learn()`**, and + **`ParallelUQLearner.teach()`** still work but now internally iterate `start()` and + collect final states into a list. Migrate to `async for state in learner.start():`. + +--- + +## [0.2.0] - 2026-02-27 + ### Added +- **RHAPSODY backend integration**: Execution backends (`RadicalExecutionBackend`, `ConcurrentExecutionBackend`) are now imported from `rhapsody-py` (`from rhapsody.backends import ...`) instead of `radical.asyncflow`. `WorkflowEngine` remains in `radical.asyncflow`. Updated all examples, tutorials, docs, and notebooks accordingly. +- **Pre-commit hooks**: Added `.pre-commit-config.yaml` with docformatter, ruff, standard file checks, actionlint, GitHub workflow validation, and typos. The `examples/use_cases/` directory is excluded from linting. +- **CI pre-commit gate**: The `tests.yml` workflow now runs pre-commit as a required job before unit and integration tests, replacing the separate `lint` job. +- **New tutorials**: Added `03-highly-parallel-surrogates` and `04-al-algorithm-selector` tutorials with corresponding optional dependencies in `tutorials/pyproject.toml` and `tutorials/README.md`. - **New `start()` API**: Replaced the blocking `teach()` method with an asynchronous iterator `start()`. This allows users to instrument the loop, log metrics in real-time (e.g., to MLflow), and implement custom early stopping or adaptive logic. - **IterationState**: Granular state reporting after each iteration, providing metrics, labeled/unlabeled counts, and statistics in a structured dataclass. - **Dynamic Configuration**: Added ability to update learner configuration (batch sizes, task arguments, etc.) between iterations using `learner.set_next_config()`. -- **Added `Mlflow` integration: `rose.learner()` is now compatible with `mlflow` tracking. This feature is to support the need by the diffusion model community to track the training process via ROSE. +- **MLflow integration**: `rose.learner()` is now compatible with MLflow tracking to support the diffusion model community's need to monitor the training process via ROSE. ### Changed -- **Async-First Execution**: The core learner logic is now `asyncio` based, enabling better concurrency and integration with modern Python stacks. -- **Separation of Concerns**: Orchestration logic (ROSE) is more clearly separated from task execution (AsyncFlow). +- **Dependency update**: `rhapsody-py[radical_pilot]` added as a core dependency; Dragon HPC backend (`rhapsody-py[dragon]`) auto-installed on Python ≤3.12 via PEP 508 environment marker. `radical.asyncflow` retained for `WorkflowEngine`. +- **Python support**: Minimum Python version is 3.10; Python 3.9 dropped from all tooling, CI, and tox environments. +- **Async-first execution**: The core learner logic is now `asyncio`-based, enabling better concurrency and integration with modern Python stacks. +- **Separation of concerns**: Orchestration logic (ROSE) is more clearly separated from task execution (AsyncFlow/RHAPSODY). +- **Package discovery**: Explicitly scoped setuptools to the `rose` package to prevent accidental inclusion of `tutorials/` and `examples/` in the distribution. +- **Ruff configuration**: Raised line length to 100, added ML naming convention rules to the ignore list (`N803`, `N806`, `N801`, `N812`–`N817`), and scoped the `B006` exception to example `run_me.py` files where `task_description={"shell": True}` is a required API pattern. +- **GitHub Actions**: Fixed unquoted `$GITHUB_ENV` shell variable in `tests.yml` and `ci.yml` (shellcheck SC2086). ### Deprecated - `learner.teach()`: This method is deprecated and will be removed in a future version. Users should migrate to the `async for state in learner.start()` pattern. diff --git a/README.md b/README.md index 0d8a1931..7f0f4b8c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ROSE is a Python package that provides tools to facilitate the development of ma ROSE also provides tools to facilitate the selection of the best surrogate model for a given simulation based on performance metrics. -ROSE uses RADICAL-Cybertools -- middleware building blocks to facilitate the development of sophisticated scientific workflows on HPC resources. +ROSE uses RADICAL-Cybertools -- middleware building blocks to facilitate the development of sophisticated scientific workflows on HPC resources. ### How to install: @@ -30,7 +30,7 @@ from rose.metrics import MEAN_SQUARED_ERROR_MSE from rose.al.active_learner import SequentialActiveLearner from radical.asyncflow import WorkflowEngine -from radical.asyncflow import RadicalExecutionBackend +from rhapsody.backends import RadicalExecutionBackend async def main(): execution_engine = await RadicalExecutionBackend( diff --git a/docs/changelog.md b/docs/changelog.md index 90cb31c6..786b75d5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1 +1 @@ ---8<-- "CHANGELOG.md" \ No newline at end of file +--8<-- "CHANGELOG.md" diff --git a/docs/getting-started/dry-run.md b/docs/getting-started/dry-run.md index b4ddda31..660061bd 100644 --- a/docs/getting-started/dry-run.md +++ b/docs/getting-started/dry-run.md @@ -42,4 +42,4 @@ asyncio.run(rose_al()) * ROSE logs the task definitions, dependencies, and flow structure. -* Useful for catching configuration errors or invalid paths before real execution. \ No newline at end of file +* Useful for catching configuration errors or invalid paths before real execution. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index fa0dd6fb..b3c2cad5 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -24,7 +24,7 @@ machines, please refer to the following link: [RADICAL-Pilot Supported HPC Machi ``` python --version ``` -4. create new pip virtual env: +4. create new pip virtual env: ``` python3 -m venv rose_env ``` diff --git a/docs/index.md b/docs/index.md index eac7e349..76823901 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,6 +11,10 @@ ROSE leverages [**RADICAL-Cybertools**](https://radical-cybertools.github.io), a ROSE allows you to enable, scale, and accelerate your learning workflows across thousands of CPU cores and GPUs effectively and efficiently with just a few lines of code. ROSE is built on the [**RADICAL-AsyncFlow**](https://radical-cybertools.github.io/radical.asyncflow/) and [**RADICAL-Pilot**](https://github.com/radical-cybertools/radical.pilot) runtime system, a powerful execution engine that enables the distributed execution of millions of scientific tasks and applications such as executables, functions and containers effortlessly. +**Preemption-safe on HPC.** Every completed iteration is written to disk before the next one starts. If the job is killed mid-run, all completed iterations are already on disk — inspect them, resume from the last checkpoint, and never rerun a finished iteration. + +**Clean separation of control and observability.** Your `async for` loop contains only decisions — `break`, `set_next_config()`, application logic. Tracking (MLflow, ClearML, file-based) is wired once with `learner.add_tracker(...)` and fires automatically at every lifecycle point. No tracking code belongs in the control loop. +
@@ -20,7 +24,7 @@ ROSE is built on the [**RADICAL-AsyncFlow**](https://radical-cybertools.github.i ## Key Features ⭐⭐⭐ -- **Express, build and run** different surrogate building worklfows on HPC such as Active, and Reinforcement Learning workflows in minutes. +- **Express, build and run** different surrogate building workflows on HPC such as Active, and Reinforcement Learning workflows in minutes. - **Seamless Execution of Complex ML surrogate building Workflows on HPC** across diverse computing platforms: - Local desktops and laptops - Local and remote clusters and grids diff --git a/docs/integrations/clearml.md b/docs/integrations/clearml.md new file mode 100644 index 00000000..d5b1ee79 --- /dev/null +++ b/docs/integrations/clearml.md @@ -0,0 +1,125 @@ +# ClearML Integration + +ROSE ships a plug-and-play `ClearMLTracker` that wires ClearML into any learner with a +single line. For parallel learners, each sub-learner's metrics appear as separate series +inside the same task — directly overlaid in the ClearML UI for convergence comparison. + +```bash +pip install rose[clearml] +``` + +--- + +## Quick start + +```python +from rose.integrations.clearml_tracker import ClearMLTracker + +learner.add_tracker( + ClearMLTracker( + project_name="ROSE-Materials-UQ", + task_name="ensemble-run-01", + ) +) + +async for state in learner.start(learner_names=["A", "B"], max_iter=15): + print(f"[{state.learner_id}] iter {state.iteration}: mse={state.metric_value:.4f}") + # tracking is fully automatic — no clearml calls here +``` + +Open the ClearML web UI, navigate to project `ROSE-Materials-UQ`, and select the task +`ensemble-run-01`. The **Scalars** tab shows overlaid curves per learner. + +A complete runnable example is at +`examples/integrations/tracking/clearml/run_me.py`. + +--- + +## What gets logged automatically + +### Hyperparameters — logged once in `on_start` + +The entire pipeline manifest is connected to the ClearML task without any user annotation: + +| ClearML hyperparameter | Source | +|---|---| +| `learner_type` | Learner class name | +| `criterion/metric_name` | `as_stop_criterion(metric_name=...)` | +| `criterion/threshold` | `as_stop_criterion(threshold=...)` | +| `criterion/operator` | `as_stop_criterion(operator=...)` | +| `task//as_executable` | Per registered task | +| `task//` | Explicit `log_params` dict declared in task decorator | + +### Scalars — logged per iteration in `on_iteration` + +| ClearML scalar | Source | +|---|---| +| `` (e.g. `mean_squared_error_mse`) | Stop criterion value | +| Any numeric key in `state.state` | Auto-extracted from task `dict` returns | + +For **parallel learners**, `state.learner_id` is included automatically. The tracker logs +each state as a separate `series` inside the same scalar title, making per-learner curves +directly comparable without any user code. + +### Task tags — logged in `on_stop` + +| ClearML tag | Value | +|---|---| +| `stop:` | `stop:criterion_met` / `stop:max_iter_reached` / `stop:stopped` / `stop:error` | +| `final_iter:` | Last completed iteration number | + +Tags make it easy to filter tasks in the ClearML UI by outcome. + +--- + +## Parallel learner comparison + +The ClearML tracker is designed with parallel learners in mind. Each `on_iteration` call +carries `state.learner_id` — the tracker logs each learner as a separate scalar series +under the same title: + +``` +Scalars tab in ClearML UI: + ┌─ mean_squared_error_mse ──────────────────────────────┐ + │ ensemble-A ───────────\ │ + │ ensemble-B ────────────\────────────────────────── │ + └───────────────────────────────────────────────────────┘ +``` + +No user code is required to achieve this — `state.learner_id` is already set by the +parallel learner framework. + +--- + +## Multiple trackers + +Attach ClearML alongside other trackers — they are independent observers: + +```python +from rose.integrations.clearml_tracker import ClearMLTracker + +learner.add_tracker(HPC_FileTracker("run.jsonl")) # safety net on HPC +learner.add_tracker(ClearMLTracker(project_name="x", task_name="y")) +``` + +--- + +## Extending `ClearMLTracker` + +To log additional artifacts (model checkpoints, prediction plots) override `on_stop`: + +```python +from rose.integrations.clearml_tracker import ClearMLTracker + +class ClearMLCheckpointTracker(ClearMLTracker): + def on_stop(self, final_state, reason: str) -> None: + # Log model checkpoint as a ClearML artifact before closing the task + if final_state and self._task: + checkpoint = final_state.get("model_checkpoint") + if checkpoint: + self._task.upload_artifact( + name="best_model", + artifact_object=checkpoint, + ) + super().on_stop(final_state, reason) +``` diff --git a/docs/integrations/mlflow.md b/docs/integrations/mlflow.md index c5d1afc9..86c9bf34 100644 --- a/docs/integrations/mlflow.md +++ b/docs/integrations/mlflow.md @@ -1,125 +1,137 @@ # MLflow Integration -This guide demonstrates how to combine **ROSE's** workflow orchestration with **MLflow's** experiment tracking to create a robust and observable active learning system. +ROSE ships a plug-and-play `MLflowTracker` that wires MLflow into any learner with a single +line. No MLflow calls belong inside your `async for` loop. -## Overview - -ROSE and MLflow provide a complementary relationship in a research or production pipeline: - -* **ROSE (Orchestration):** Manages task execution order, dependencies, high-performance computing (HPC) resources, and the iterative loop. -* **MLflow (Tracking):** Records hyperparameters, performance metrics, trained models, and diagnostic plots for analysis and reproducibility. - -| Tool | Role | Focus | -|------|------|-------| -| **ROSE** | Orchestrator | *What* runs? *When*? *Where*? In what order? | -| **MLflow** | Tracker | *What happened*? How well did it perform? Can I reproduce it? | +```bash +pip install rose[mlflow] +``` --- -## Installation +## Quick start + +```python +from rose.integrations.mlflow_tracker import MLflowTracker + +# Register tasks before attaching the tracker +@learner.training_task(as_executable=False, log_params={"kernel": "rbf"}) +async def train(*args, **kwargs): ... + +# add_tracker fires on_start(manifest) immediately — tasks must already be registered +learner.add_tracker( + MLflowTracker( + experiment_name="surrogate-v1", + run_name="gp-adaptive-kernel", # optional + ) +) + +async for state in learner.start(max_iter=30): + print(f"iter {state.iteration}: mse={state.metric_value:.4f}") + # tracking is fully automatic — no mlflow calls here +``` -To use this integration, you need both `mlflow` and `ROSE` installed in your environment. +View results: ```bash -# Install MLflow -pip install mlflow - -# Optional: for visualization logic in the example -pip install matplotlib scikit-learn +mlflow ui --port 5000 +# Open http://localhost:5000 → experiment "surrogate-v1" ``` +A complete runnable example is at +`examples/integrations/tracking/mlflow/run_me_tracker.py`. + --- -## Quick Start +## What gets logged automatically -You can find a complete integration example in the codebase at `examples/integrations/mlflow/mlflow_rose.py`. +### Parameters — logged once in `on_start` -```bash -# Run the integration example -python examples/integrations/mlflow/mlflow_rose.py +The entire pipeline manifest is logged as MLflow parameters without any user annotation: -# Launch the MLflow UI to view results -mlflow ui --port 5000 -``` +| MLflow param | Source | +|---|---| +| `learner_type` | Learner class name | +| `criterion/metric_name` | `as_stop_criterion(metric_name=...)` | +| `criterion/threshold` | `as_stop_criterion(threshold=...)` | +| `criterion/operator` | `as_stop_criterion(operator=...)` | +| `task..as_executable` | Per registered task | +| `task..` | Explicit `log_params` dict declared in task decorator | -Once the UI is running, open [http://localhost:5000](http://localhost:5000) in your browser. +### Metrics — logged per iteration in `on_iteration` ---- +| MLflow metric | Source | +|---|---| +| `` (e.g. `mean_squared_error_mse`) | Stop criterion value | +| Any scalar in `state.state` | Auto-extracted from task `dict` returns | -## Integration Pattern +Every key returned in a task's `dict` result appears as a metric — zero annotation required. -The standard pattern for integrating MLflow into a ROSE `SequentialActiveLearner` loop involves wrapping the learner's `start()` iterator: +### Tags — logged in `on_stop` -```python -import mlflow -from rose.al import SequentialActiveLearner - -async def main(): - # 1. Initialize MLflow Run - mlflow.set_experiment("ROSE_AL_Experiment") - - with mlflow.start_run(): - # 2. Log Configuration - mlflow.log_params({ - "max_iterations": 10, - "mse_threshold": 0.01, - }) - - # 3. Setup ROSE Learner - learner = SequentialActiveLearner(asyncflow) - # ... register tasks ... - - # 4. Instrument the Control Loop - async for state in learner.start(max_iter=10): - # Log metrics at each iteration step - mlflow.log_metric("mse", state.metric_value, step=state.iteration) - mlflow.log_metric("labeled_count", state.labeled_count, step=state.iteration) - - print(f"Iteration {state.iteration}: MSE {state.metric_value}") - - # 5. Log Final Artifacts - mlflow.sklearn.log_model(final_model, "surrogate_model") -``` +| MLflow tag | Value | +|---|---| +| `stop_reason` | `"criterion_met"` / `"max_iter_reached"` / `"stopped"` / `"error"` | +| `final_iteration` | Last completed iteration number | --- -## What is Tracked? - -### Parameters -Parameters are typically logged once at the beginning of the run to record the experimental setup. -* Iteration limits -* Stopping criteria thresholds -* Initial sample sizes -* Batch selection counts +## Adaptive config changes -### Metrics -Metrics are logged at **each iteration step** using the `step` parameter in `mlflow.log_metric()`. This allows you to view learning curves and performance trends over time in the MLflow UI. -* **Performance:** MSE, Accuracy, R-squared -* **Workflow State:** Number of labeled samples, remaining pool size -* **Adaptive Features:** Current uncertainty scores, selection batch sizes +When you call `learner.set_next_config(config)` to change hyperparameters between iterations, +the new config appears in the next `IterationState.current_config`. MLflow captures this +automatically in `on_iteration` — no manual `log_params()` call needed. -### Artifacts and Model Registry -At the end of the ROSE workflow, you can save: -* **The Model:** Register the final surrogate model in the MLflow Model Registry for deployment. -* **Visualizations:** Save plots of error reduction vs. iteration or sample size. -* **Data States:** Save the final labeled dataset for future reference. +```python +configs = { + 0: LearnerConfig(training=TaskConfig(kwargs={"--lr": 3e-4})), + 10: LearnerConfig(training=TaskConfig(kwargs={"--lr": 1e-4})), + 20: LearnerConfig(training=TaskConfig(kwargs={"--lr": 3e-5})), +} + +async for state in learner.start(max_iter=30): + next_iter = state.iteration + 1 + if next_iter in configs: + learner.set_next_config(configs[next_iter]) + # MLflow records the config change — no manual call needed +``` --- -## Advanced: MLflowROSETracker Helper +## Multiple trackers -For more complex workflows, the provided example includes an `MLflowROSETracker` helper class. It encapsulates common tracking logic, making the main workflow code cleaner: +Attach MLflow alongside other trackers — they are independent observers: ```python -tracker = MLflowROSETracker("My_Complex_Experiment") -tracker.start_experiment(config) - -async for state in learner.start(max_iter=15): - # Automatically handles extraction and logging of relevant metrics - tracker.log_iteration(state) +from rose.integrations.mlflow_tracker import MLflowTracker -tracker.log_model(model, X_sample, y_sample) -tracker.end_experiment(success=True) +learner.add_tracker(HPC_FileTracker("run.jsonl")) # safety net +learner.add_tracker(MLflowTracker(experiment_name="x")) # experiment comparison ``` -For the full implementation of this helper, see the [mlflow_rose.py source code](https://github.com/radical-cybertools/ROSE/blob/main/examples/integrations/mlflow/mlflow_rose.py). +--- + +## `MLflowTracker` vs manual wiring + +The previous ROSE documentation showed a manual pattern where MLflow calls were placed +inside the `async for` loop. That approach is now deprecated in favour of `add_tracker()`. + +| | `MLflowTracker` | Manual wiring | +|---|---|---| +| Pipeline manifest as params | Automatic | Must write `log_params(...)` manually | +| Metrics per iteration | Automatic | Must call `log_metric(...)` inside loop | +| Stop reason tag | Automatic | Requires try/finally | +| MLflow code in control loop | None | Yes | + +!!! tip + If you need to log model artifacts (e.g. `mlflow.sklearn.log_model`) or custom plots, + add that logic to a subclass of `MLflowTracker` by overriding `on_stop`: + + ```python + class MLflowArtifactTracker(MLflowTracker): + def on_stop(self, final_state, reason: str) -> None: + super().on_stop(final_state, reason) + if final_state and reason in ("criterion_met", "max_iter_reached"): + model = load_model(final_state.get("checkpoint_path")) + mlflow.sklearn.log_model(model, artifact_path="surrogate_model") + ``` diff --git a/docs/styles/custom.css b/docs/styles/custom.css index 40ee6626..c21edcfb 100644 --- a/docs/styles/custom.css +++ b/docs/styles/custom.css @@ -38,7 +38,7 @@ table#config td.type { font-size: 0.8em; } table#config tr:hover code { - background-color: hsla(0, 0%, 100%, 1); + background-color: hsla(0, 0%, 100%, 1); } table#config td:first-child { white-space: nowrap; @@ -59,4 +59,4 @@ table#config td.type { } .md-typeset__scrollwrap { overflow-x: inherit; -} \ No newline at end of file +} diff --git a/docs/user-guide/acl-metrics.md b/docs/user-guide/acl-metrics.md index 07c52e9b..d8d71dc9 100644 --- a/docs/user-guide/acl-metrics.md +++ b/docs/user-guide/acl-metrics.md @@ -25,4 +25,4 @@ async def check_metric(*args): return f'python3 check_custom_metric.py' ``` -In this way, ROSE will understand the relation between the custom metric and the target threshold value. \ No newline at end of file +In this way, ROSE will understand the relation between the custom metric and the target threshold value. diff --git a/docs/user-guide/advanced-acl-workflow.md b/docs/user-guide/advanced-acl-workflow.md index 73c443fb..6c501bcc 100644 --- a/docs/user-guide/advanced-acl-workflow.md +++ b/docs/user-guide/advanced-acl-workflow.md @@ -4,10 +4,10 @@ In this example, we demonstrate how to express an AL workflow with different lev In some cases, AL workflows may require the execution of N simulation or training tasks **concurrently**. But not only that—additionally, they may also require the submission of M AL workflows concurrently. This introduces two levels of parallelism: one at the task level and another at the AL workflow level. Such an approach is possible and can be easily expressed and executed using ROSE's **custom AL policy**. -```sh +```sh (N AL WFs in Parallel) +-------------------+ +-------------------+ - | AL WF 1 | | AL WF 2 | + | AL WF 1 | | AL WF 2 | +-------------------+ +-------------------+ │ │ +----------------+-----------------+ +----------------+-----------------+ @@ -20,7 +20,7 @@ In some cases, AL workflows may require the execution of N simulation or trainin | Training 1 | | Training 2 | | Training 1 | | Training 2 | +---------------+ +---------------+ +---------------+ +---------------+ | | | | - (...) (...) (...) (...) + (...) (...) (...) (...) ``` Since we have already learned how to deploy and load ROSE, and how to instruct it to use different resources, we will skip this part and focus only on expressing the AL workflow. @@ -59,7 +59,7 @@ async def post_process_simulation(*args): Now, lets express the core custom AL policy logic. The example below will: * Submits 5 AL workflows in parallel (Workflow parallelism). -* Each workflow will run for 10 iterations sequentially. +* Each workflow will run for 10 iterations sequentially. * Each iteration will submit 3 simulation tasks in parallel (task parallelism). diff --git a/docs/user-guide/advanced-rl-workflow.md b/docs/user-guide/advanced-rl-workflow.md index 4fe1a874..25323987 100644 --- a/docs/user-guide/advanced-rl-workflow.md +++ b/docs/user-guide/advanced-rl-workflow.md @@ -1,39 +1,39 @@ -In addition to basic reinforcement learning (RL) workflows, ROSE supports advanced RL workflows that can run multiple environment instances in parallel. +In addition to basic reinforcement learning (RL) workflows, ROSE supports advanced RL workflows that can run multiple environment instances in parallel. -The 'ParallelLearner' gives you the ability to run multiple environment tasks simultaneously, each with different parameters, and then merge their experiences for training. +The 'ParallelLearner' gives you the ability to run multiple environment tasks simultaneously, each with different parameters, and then merge their experiences for training. This is particularly useful for scenarios where you want to explore different configurations or hyperparameters in parallel, speeding up the learning process. -```sh +```sh +-------------------+ | RL WF | +-------------------+ │ - +-------------------------+---------------------------+ - | (N Environment Tasks Parallel) | - +---------------+ +---------------+ +---------------+ - | Environment 1 | | Environment 2 | | Environment 3 | - +---------------+ +---------------+ +---------------+ + +-------------------------+---------------------------+ + | (N Environment Tasks Parallel) | + +---------------+ +---------------+ +---------------+ + | Environment 1 | | Environment 2 | | Environment 3 | + +---------------+ +---------------+ +---------------+ | | | └────────────────┼────────────────────┘ │ - +------v------+ - | Merge | - +------+------+ - │ - +------v------+ - | Update | - +------+------+ - │ - +------v------+ - | Test | - +-------------+ + +------v------+ + | Merge | + +------+------+ + │ + +------v------+ + | Update | + +------+------+ + │ + +------v------+ + | Test | + +-------------+ ``` Import ROSE parallel RL modules: ```python from radical.asyncflow import WorkflowEngine -from radical.asyncflow import RadicalExecutionBackend +from rhapsody.backends import RadicalExecutionBackend from rose.rl.reinforcement_learner import SequentialReinforcementLearner ``` @@ -104,4 +104,4 @@ await pe.learn() await engine.shutdown() ``` -This advanced workflow allows you to efficiently explore multiple configurations in parallel, leveraging ROSE's capabilities to manage and merge experiences seamlessly. The `ParallelExperience` learner is particularly useful for scenarios where you want to speed up the learning process by running multiple environment instances concurrently, each with different parameters or hyperparameter. \ No newline at end of file +This advanced workflow allows you to efficiently explore multiple configurations in parallel, leveraging ROSE's capabilities to manage and merge experiences seamlessly. The `ParallelExperience` learner is particularly useful for scenarios where you want to speed up the learning process by running multiple environment instances concurrently, each with different parameters or hyperparameter. diff --git a/docs/user-guide/basic-acl-workflow.md b/docs/user-guide/basic-acl-workflow.md index b3f59af2..4ff8d701 100644 --- a/docs/user-guide/basic-acl-workflow.md +++ b/docs/user-guide/basic-acl-workflow.md @@ -6,7 +6,7 @@ from rose.metrics import MEAN_SQUARED_ERROR_MSE from rose.al.active_learner import SequentialActiveLearner from radical.asyncflow import WorkflowEngine -from radical.asyncflow import RadicalExecutionBackend +from rhapsody.backends import RadicalExecutionBackend ``` diff --git a/docs/user-guide/basic-rl-workflow.md b/docs/user-guide/basic-rl-workflow.md index b5abb6e5..1eeb2711 100644 --- a/docs/user-guide/basic-rl-workflow.md +++ b/docs/user-guide/basic-rl-workflow.md @@ -3,7 +3,7 @@ Import ROSE main modules: ```python from radical.asyncflow import WorkflowEngine -from radical.asyncflow import RadicalExecutionBackend +from rhapsody.backends import RadicalExecutionBackend from rose.metrics import GREATER_THAN_THRESHOLD from rose.rl.reinforcement_learner import SequentialReinforcementLearner diff --git a/docs/user-guide/experience.md b/docs/user-guide/experience.md index 665b5ca7..59155772 100644 --- a/docs/user-guide/experience.md +++ b/docs/user-guide/experience.md @@ -65,4 +65,3 @@ bank1.merge_inplace(bank2) # Find all bank files in directory bank_files = ExperienceBank.list_saved_banks("./data") ``` - diff --git a/docs/user-guide/parallel_learners_docs.md b/docs/user-guide/parallel_learners_docs.md index 4a44b3e8..b5cfe5f0 100644 --- a/docs/user-guide/parallel_learners_docs.md +++ b/docs/user-guide/parallel_learners_docs.md @@ -1,11 +1,11 @@ # Learners with Parameterization Tutorial -This tutorial demonstrates how to configure and run multiple learning pipelines concurrently using `ParallelActiveLearner`. You’ll learn how to: +This tutorial demonstrates how to configure and run multiple learning pipelines concurrently using `ParallelActiveLearner`. You'll learn how to: - Set up parallel workflows - Configure each learner independently - Use per-iteration and adaptive configurations -- Run learners concurrently with individual stop criteria +- Stream per-learner states in real time as each iteration completes --- @@ -21,12 +21,31 @@ This approach can be applied for both Active and Reinforcement learners (Sequent - **Learner 1**: Per-iteration config — specific checkpoints for tuning - **Learner 2**: Static config — constant settings throughout - All learners run **concurrently and independently** +- States from all learners are **streamed in real time** via `async for` + +--- + +## How the API Works + +`ParallelActiveLearner.start()` returns an **async iterator** that yields an `IterationState` +each time any parallel learner completes an iteration. States arrive in completion order — not +grouped by learner — so you react to results as they happen. + +Each `IterationState` carries a `learner_id` (integer index) identifying which learner produced it: + +```python +async for state in acl.start(parallel_learners=3, max_iter=10): + print(f"Learner {state.learner_id} | iter {state.iteration} | MSE {state.metric_value:.4f}") +``` + +This is the same interface used by `SequentialActiveLearner`, so code that consumes +`IterationState` works identically for both sequential and parallel learners. --- ## Configuration Modes -### 🧠 Adaptive Configuration +### Adaptive Configuration - Receives iteration number `i` - Labeled data: `100 + i*50` @@ -34,7 +53,7 @@ This approach can be applied for both Active and Reinforcement learners (Sequent - Learning rate: `0.01 * (0.9^i)` - Batch size increases gradually, capped at 64 -### 🔁 Per-Iteration Configuration +### Per-Iteration Configuration - Iteration keys (e.g., `0`, `5`, `10`) set exact checkpoints - `-1` is the fallback/default config @@ -61,7 +80,7 @@ from rose.al import ParallelActiveLearner from rose.metrics import MEAN_SQUARED_ERROR_MSE from radical.asyncflow import WorkflowEngine -from radical.asyncflow import RadicalExecutionBackend +from rhapsody.backends import RadicalExecutionBackend engine = await RadicalExecutionBackend( {'runtime': 30, @@ -73,7 +92,7 @@ acl = ParallelActiveLearner(asyncflow) code_path = f'{sys.executable} {os.getcwd()}' ``` -### 1. Define Workflow Tasks +### 2. Define Workflow Tasks ```python @acl.simulation_task async def simulation(*args, **kwargs): @@ -100,7 +119,7 @@ async def check_mse(*args, **kwargs): ### Approach 1: Static Configuration ```python -results = await acl.start( +async for state in acl.start( parallel_learners=2, max_iter=10, learner_configs=[ @@ -113,12 +132,13 @@ results = await acl.start( training=TaskConfig(kwargs={"--learning_rate": "0.005"}) ) ] -) +): + print(f"[Learner {state.learner_id}] iter={state.iteration} | MSE={state.metric_value}") ``` ### Approach 2: Per-Iteration Configuration ```python -results = await acl.start( +async for state in acl.start( parallel_learners=3, max_iter=15, learner_configs=[ @@ -140,7 +160,8 @@ results = await acl.start( ), None # Default to base task behavior ] -) +): + print(f"[Learner {state.learner_id}] iter={state.iteration} | MSE={state.metric_value}") ``` !!! tip "Per-Iteration Config Keys" @@ -149,7 +170,7 @@ Use numeric keys for specific iterations and -1 as a fallback. ### Approach 3: Adaptive Configuration ```python -adaptive_sim = acl.create_adaptive_schedule('simulation', +adaptive_sim = acl.create_adaptive_schedule('simulation', lambda i: { 'kwargs': { '--n_labeled': str(100 + i * 50), @@ -166,7 +187,7 @@ adaptive_train = acl.create_adaptive_schedule('training', } }) -results = await acl.start( +async for state in acl.start( parallel_learners=2, max_iter=20, learner_configs=[ @@ -176,13 +197,14 @@ results = await acl.start( training=TaskConfig(kwargs={"--learning_rate": "0.005"}) ) ] -) +): + print(f"[Learner {state.learner_id}] iter={state.iteration} | MSE={state.metric_value}") ``` ### Full Example: All Approaches Combined ```python -adaptive_sim = acl.create_adaptive_schedule('simulation', +adaptive_sim = acl.create_adaptive_schedule('simulation', lambda i: { 'kwargs': { '--n_labeled': str(100 + i * 50), @@ -190,7 +212,10 @@ adaptive_sim = acl.create_adaptive_schedule('simulation', } }) -results = await acl.start( +# Collect the final state per learner if needed +final_states = {} + +async for state in acl.start( parallel_learners=3, max_iter=15, learner_configs=[ @@ -206,7 +231,9 @@ results = await acl.start( simulation=TaskConfig(kwargs={"--n_labeled": "300", "--n_features": 4}) ) ] -) +): + print(f"[Learner {state.learner_id}] iter={state.iteration} | MSE={state.metric_value}") + final_states[state.learner_id] = state # keep last state per learner await acl.shutdown() ``` @@ -214,7 +241,11 @@ await acl.shutdown() ### Execution Details !!! note "Concurrent Execution" -All learners run in parallel and independently. The workflow completes when all learners either reach max_iter or meet their stop criterion. +All learners run in parallel and independently. States are yielded in arrival order — whichever learner finishes an iteration first yields next. The loop completes when all learners either reach `max_iter` or meet their stop criterion. + +!!! note "Identifying the Source Learner" +Each `IterationState` has a `learner_id` field (integer index, 0-based) so you can distinguish +which learner produced each state inside the loop. !!! warning "Stop Criteria" Each learner evaluates its own stop condition. One learner stopping does not affect others. @@ -248,10 +279,10 @@ adaptive_config = acl.create_adaptive_schedule('training', lr_decay) ## Next Steps -- 🧪 Try different active learning algorithms per learner +- Try different active learning algorithms per learner -- 🎯 Use per-iteration configs to design curriculum learning +- Use per-iteration configs to design curriculum learning -- 📊 Run parameter sweeps +- Run parameter sweeps across acquisition functions or model architectures -- 🚀 Scale learners to match compute resources +- Scale learners to match compute resources diff --git a/docs/user-guide/target-resources.md b/docs/user-guide/target-resources.md index c256f8e4..eb6add8c 100644 --- a/docs/user-guide/target-resources.md +++ b/docs/user-guide/target-resources.md @@ -1,5 +1,5 @@ # Target Machines for Executing AL Workflows -ROSE enables the orchestration of ML Surrogate building workflows on diverse computing resources using [radical.asyncflow](https://github.com/radical-cybertools/radical.asyncflow). Below, we will show how you can specify your `local computer` and `remote HPC machine` as target resources using the `RadicalExecutionBackend`. +ROSE enables the orchestration of ML Surrogate building workflows on diverse computing resources using [radical.asyncflow](https://github.com/radical-cybertools/radical.asyncflow). Below, we will show how you can specify your `local computer` and `remote HPC machine` as target resources using the `RadicalExecutionBackend` from [RHAPSODY](https://github.com/radical-cybertools/rhapsody). ## Local Computer For local execution, user can use their desktops, laptops, and their own small clusters to execute their AL workflows as follows: @@ -7,7 +7,7 @@ For local execution, user can use their desktops, laptops, and their own small c import os from radical.asyncflow import WorkflowEngine -from radical.asyncflow import RadicalExecutionBackend +from rhapsody.backends import RadicalExecutionBackend from rose.al.active_learner import SequentialActiveLearner @@ -21,13 +21,13 @@ acl = SequentialActiveLearner(asyncflow) ``` ## HPC Resources -To execute AL workflows on HPC machines, users must have an active allocation on the target machine and specify their resource requirements, as well as the time needed to execute their workflows. Remember, ROSE uses `RadicalExecutionBackend` from RADICAL-AsyncFlow which is an interface for RADICAL-Pilot runtime system. For more information on how to access, set up, and execute workflows on HPC machines, refer to the following link [RADICAL-Pilot Job Submission](https://radicalpilot.readthedocs.io/en/stable/tutorials/submission.html): +To execute AL workflows on HPC machines, users must have an active allocation on the target machine and specify their resource requirements, as well as the time needed to execute their workflows. Remember, ROSE uses `RadicalExecutionBackend` from [RHAPSODY](https://github.com/radical-cybertools/rhapsody) (`rhapsody-py`) which is an interface for RADICAL-Pilot runtime system. For more information on how to access, set up, and execute workflows on HPC machines, refer to the following link [RADICAL-Pilot Job Submission](https://radicalpilot.readthedocs.io/en/stable/tutorials/submission.html): ```python import os from radical.asyncflow import WorkflowEngine -from radical.asyncflow import RadicalExecutionBackend +from rhapsody.backends import RadicalExecutionBackend from rose.al.active_learner import SequentialActiveLearner @@ -39,4 +39,4 @@ hpc_engine = await RadicalExecutionBackend( asyncflow = await WorkflowEngine.create(hpc_engine) acl = SequentialActiveLearner(asyncflow) -``` \ No newline at end of file +``` diff --git a/docs/user-guide/tracking.md b/docs/user-guide/tracking.md new file mode 100644 index 00000000..cd34c411 --- /dev/null +++ b/docs/user-guide/tracking.md @@ -0,0 +1,409 @@ +# Tracking and Observability + +ROSE has a pluggable tracking system that lets you record what happened in a run — metrics, +pipeline configuration, stop reason — without changing a single line of your workflow code. + +You attach a tracker **once**, after registering all tasks and before calling `start()`. +The learner calls it automatically at every lifecycle point. No tracking code belongs +inside your `async for` loop. + +```python +# 1. Register tasks first +@learner.training_task(as_executable=False) +async def train(*args, **kwargs): ... + +# 2. Attach tracker — on_start(manifest) fires here with the complete pipeline manifest +learner.add_tracker(MyTracker(...)) + +# 3. Start the loop +async for state in learner.start(max_iter=20): + # your control logic only — tracking is fully automatic + if state.metric_value and state.metric_value < 0.001: + break +``` + +`add_tracker()` fires `on_start(manifest)` immediately. All task decorators must have been +applied beforehand so the manifest is complete. + +--- + +## Two data channels + +ROSE separates **control** data from **observability** data. They serve different purposes and +flow through different channels. + +| Channel | Object | Purpose | Lifetime | +|---------|--------|---------|----------| +| **Control** | `IterationState` | User reads it to decide `break`, `set_next_config()`, etc. | Ephemeral — a fresh object each iteration | +| **Observability** | Tracker callbacks | Record what happened for reproducibility, debugging, comparison | Persistent — written to disk / database / external service | + +The **same `IterationState` object is the source for both channels.** The tracker adds context +(run identity, pipeline manifest, stop reason) that is irrelevant to the control loop. + +--- + +## The `TrackerBase` protocol + +Any class with these three methods is a valid tracker. All methods have default no-op +implementations — implement only what you need. + +```python +from rose import TrackerBase, PipelineManifest, IterationState + +class MyTracker(TrackerBase): + + def on_start(self, manifest: PipelineManifest) -> None: + """Called once inside add_tracker(), immediately when it is invoked. + + All task decorators must have fired before add_tracker() is called — + the manifest is built at that moment and contains the full pipeline. + """ + + def on_iteration(self, state: IterationState) -> None: + """Called once per iteration, just before yield state. + + state.state contains the consolidated snapshot of all task outputs + extracted from dict return values during this iteration. + """ + + def on_stop(self, final_state: IterationState | None, reason: str) -> None: + """Called once in the finally block of start(). + + Fires on normal completion, criterion met, external stop(), user break, + and exceptions. reason is one of: + "criterion_met" — stop criterion threshold was reached + "max_iter_reached" — all iterations completed normally + "stopped" — learner.stop() was called or user broke the loop + "error" — an unhandled exception occurred + """ +``` + +### Lifecycle diagram + +``` +# Register tasks first, then: +add_tracker(t) + └─ t.on_start(manifest) ← fires immediately; manifest is complete because tasks were registered first + +start() called + ├─ iteration 0 + │ ├─ simulation task → returns {"n_labeled": 25, ...} + │ ├─ training task → returns {"train_mse": 0.03, "lml": -10.1, ...} + │ ├─ active_learn task + │ ├─ criterion task + │ ├─ build_iteration_state() → IterationState snapshot (state.state from dict returns) + │ ├─ t.on_iteration(state) ← full snapshot with all state keys + │ └─ yield state ← user's async for loop body runs + │ + ├─ iteration 1 ... (same pattern) + │ + └─ finally + └─ t.on_stop(final_state, reason) ← always fires +``` + +--- + +## `PipelineManifest` — what `on_start` receives + +`PipelineManifest` captures the full pipeline definition at decoration time. It is built +automatically from the task function dicts already populated by the decorators — no user +annotation required. + +```python +@dataclass +class TaskManifest: + func_name: str # decorated function's __name__ + func_module: str # decorated function's __module__ + as_executable: bool # True = executable_task, False = function_task + decor_kwargs: dict # HPC backend kwargs (num_gpus, ranks, memory) — opaque to trackers + log_params: dict # explicit tracking metadata declared at decoration time + +@dataclass +class CriterionManifest(TaskManifest): + metric_name: str # e.g. "mean_squared_error_mse" + threshold: float # e.g. 0.01 + operator: str # e.g. "<" + +@dataclass +class PipelineManifest: + learner_type: str # class name, e.g. "SequentialActiveLearner" + tasks: dict[str, TaskManifest] # keyed: "simulation", "training", etc. + criterion: CriterionManifest | None + parallel_count: int | None # None for sequential +``` + +Example — reading the manifest in `on_start`: + +```python +def on_start(self, manifest: PipelineManifest) -> None: + print(manifest.learner_type) # "SequentialActiveLearner" + print(manifest.tasks.keys()) # dict_keys(['simulation', 'training', 'active_learn']) + print(manifest.criterion.metric_name) # "mean_squared_error_mse" + print(manifest.criterion.threshold) # 0.01 + print(manifest.tasks["training"].as_executable) # False + print(manifest.tasks["training"].log_params) # {"num_gpus": 4, "kernel": "rbf"} + # manifest.tasks["training"].decor_kwargs holds HPC backend args — not logged by trackers +``` + +### Two-channel decorator design + +Task decorators accept two separate keyword groups with different destinations: + +```python +@learner.training_task( + as_executable=False, + num_gpus=4, # → HPC backend only, opaque to trackers + log_params={"num_gpus": 4, "kernel": "rbf"} # → trackers only, via TaskManifest.log_params +) +async def training(*args, **kwargs): + ... +``` + +- **`decor_kwargs`** (any keyword not recognised by ROSE) flow to the HPC execution backend + (RADICAL-Pilot, Ray, etc.) as task requirements. They are intentionally not logged — + they may include resource specs, auth tokens, or backend-specific values. +- **`log_params`** is the explicit opt-in channel: only what you list here reaches trackers. + Nothing is logged unless you declare it. + +--- + +## How task outputs reach the tracker + +When a task returns a `dict`, ROSE automatically extracts each key-value pair into +`IterationState.state`. These values are then available in every `on_iteration` call: + +```python +@learner.training_task(as_executable=False) +async def training(*args, **kwargs): + model = fit_model(...) + return { + "train_loss": 0.032, # → state.state["train_loss"] + "val_loss": 0.041, # → state.state["val_loss"] + "n_params": 125000, # → state.state["n_params"] + } +# tracker.on_iteration sees state.state = {"train_loss": 0.032, "val_loss": 0.041, ...} +``` + +### Reading state from `IterationState` + +```python +async for state in learner.start(max_iter=20): + # Access via attribute-style shortcut + print(state.train_loss) # registered as "train_loss" by training task + print(state.n_labeled) # registered as "n_labeled" by active_learn task + + # Or via get() with a default + lml = state.get("log_marginal_likelihood", default=None) + + # Or iterate all registered keys + for key, value in state.state.items(): + print(f" {key} = {value}") +``` + +--- + +## Parallel learner tracking + +For `ParallelActiveLearner`, `ParallelReinforcementLearner`, and `ParallelUQLearner`, +each `IterationState` carries a `learner_id` identifying the sub-learner. Use `on_iteration` +to observe all sub-learners — the `IterationState.state` dict contains the full consolidated +snapshot from all tasks run by that sub-learner: + +```python +def on_iteration(self, state: IterationState) -> None: + # state.learner_id = "learner-0", "learner-1", etc. + # state.state = full snapshot from that sub-learner's tasks + print(f"[{state.learner_id}] iter={state.iteration} metric={state.metric_value}") + for key, value in state.state.items(): + if isinstance(value, (int, float)): + self.log_metric(f"{state.learner_id}/{key}", value, step=state.iteration) +``` + +--- + +## Built-in trackers + +### HPC FileTracker — no external dependencies + +The simplest production tracker: append-only JSON Lines file. One record per event. +Atomic at the POSIX level — survives job preemption with all completed iterations intact. + +```bash +pip install rose scikit-learn numpy +python examples/integrations/tracking/run_me.py +``` + +```python +import json, time +from pathlib import Path +from rose import TrackerBase, PipelineManifest, IterationState + +class HPC_FileTracker(TrackerBase): + """Append-only JSON Lines tracker — safe for HPC job preemption.""" + + def __init__(self, path: str) -> None: + self._path = Path(path) + self._path.write_text("") + self._t0 = 0.0 + + def on_start(self, manifest: PipelineManifest) -> None: + self._t0 = time.time() + self._write({ + "event": "start", + "learner_type": manifest.learner_type, + "criterion": { + "metric": manifest.criterion.metric_name, + "threshold": manifest.criterion.threshold, + } if manifest.criterion else None, + }) + + def on_iteration(self, state: IterationState) -> None: + self._write({ + "event": "iteration", + "iteration": state.iteration, + "elapsed_s": round(time.time() - self._t0, 3), + "metric": state.metric_value, + "should_stop": state.should_stop, + **{k: v for k, v in state.state.items() + if isinstance(v, (int, float, str, bool))}, + }) + + def on_stop(self, final_state, reason: str) -> None: + self._write({ + "event": "stop", + "reason": reason, + "elapsed_s": round(time.time() - self._t0, 3), + "final_iteration": final_state.iteration if final_state else None, + }) + + def _write(self, record: dict) -> None: + with self._path.open("a") as f: + f.write(json.dumps(record) + "\n") + +# Usage +learner.add_tracker(HPC_FileTracker("run.jsonl")) + +# Post-processing +# import pandas +# df = pandas.read_json("run.jsonl", lines=True) +# df[df.event == "iteration"].plot(x="iteration", y="metric", logy=True) +``` + +### MLflow Tracker + +Logs the pipeline manifest as run parameters, per-iteration metrics as MLflow scalars, and +stop reason as a tag. See [MLflow Integration](../integrations/mlflow.md) for full details. + +```bash +pip install rose[mlflow] +python examples/integrations/tracking/mlflow/run_me_tracker.py +``` + +```python +from rose.integrations.mlflow_tracker import MLflowTracker + +learner.add_tracker( + MLflowTracker( + experiment_name="surrogate-v1", + run_name="gp-adaptive-kernel", + ) +) +async for state in learner.start(max_iter=30): + ... # no mlflow calls here +``` + +### ClearML Tracker + +Logs hyperparameters, per-learner scalar curves, and stop tags. Parallel learner runs appear +as separate series in the same task — directly comparable in the ClearML UI. +See [ClearML Integration](../integrations/clearml.md) for full details. + +```bash +pip install rose[clearml] +python examples/integrations/tracking/clearml/run_me.py +``` + +```python +from rose.integrations.clearml_tracker import ClearMLTracker + +learner.add_tracker( + ClearMLTracker( + project_name="ROSE-Materials-UQ", + task_name="ensemble-run-01", + ) +) +async for state in learner.start(learner_names=["A", "B"], max_iter=15): + ... # no clearml calls here +``` + +--- + +## Stacking multiple trackers + +Trackers are independent observers. If one raises an exception the others are unaffected and +the learner continues. + +```python +learner.add_tracker(HPC_FileTracker("run.jsonl")) # always-on safety net +learner.add_tracker(MLflowTracker(experiment_name="x")) # experiment comparison +learner.add_tracker(ClearMLTracker(project_name="x", task_name="y")) + +async for state in learner.start(max_iter=20): + ... # all three trackers fire at every lifecycle point +``` + +--- + +## Writing a custom tracker + +Any class with those three methods is a valid tracker — no import from ROSE required, no +registration, no base class to inherit. The `TrackerBase` class provides default no-ops so +you only need to define the methods you care about: + +```python +from rose import TrackerBase # optional — provides default no-ops + +class SlackTracker(TrackerBase): + """Post a Slack message when the run finishes.""" + + def __init__(self, webhook_url: str) -> None: + self._url = webhook_url + self._iters = 0 + + def on_iteration(self, state) -> None: + self._iters += 1 + + def on_stop(self, final_state, reason: str) -> None: + import urllib.request, json as _json + metric = f"{final_state.metric_value:.4f}" if final_state else "N/A" + msg = (f"ROSE run finished — {self._iters} iterations, " + f"reason: {reason}, final metric: {metric}") + data = _json.dumps({"text": msg}).encode() + req = urllib.request.Request( + self._url, data=data, + headers={"Content-Type": "application/json"}, + ) + urllib.request.urlopen(req) + +learner.add_tracker(SlackTracker("https://hooks.slack.com/services/...")) +``` + +The same pattern works for any backend: databases (SQLite, PostgreSQL), cloud storage +(S3, GCS), monitoring tools (Prometheus, Grafana), or custom dashboards. + +--- + +## `add_tracker()` vs manual wiring + +| | `add_tracker()` | Manual wiring in `async for` | +|---|---|---| +| Params logged | Automatically from manifest | You write `log_params(...)` | +| Metrics logged | Automatically each iteration | You write `log_metric(...)` inside loop | +| Stop reason | Automatically via `on_stop` | You need try/finally or post-loop logic | +| Tracker code in control loop | None | Yes | +| Works with parallel learners | Yes | Yes (but `state.learner_id` must be handled manually) | + +!!! tip + If you find yourself writing tracking calls inside your `async for` loop, that logic belongs + in a `TrackerBase` subclass instead. The loop body should contain only decisions: + `break`, `set_next_config()`, and application-level logic. diff --git a/docs/user-guide/uq_based-acl-workflow.md b/docs/user-guide/uq_based-acl-workflow.md index 37a315b4..24cfc90f 100644 --- a/docs/user-guide/uq_based-acl-workflow.md +++ b/docs/user-guide/uq_based-acl-workflow.md @@ -10,10 +10,10 @@ This introduces **two levels of parallelism**: * **Workflow-level parallelism** for executing multiple UQ–AL loops side by side. Both levels can be naturally expressed and efficiently executed using ROSE’s **custom AL policy**, enabling scalable and adaptive uncertainty-aware learning. -```sh +```sh (N AL WFs in Parallel) +-------------------+ +-------------------+ - | UQ WF 1 | | UQ WF 2 | + | UQ WF 1 | | UQ WF 2 | +-------------------+ +-------------------+ │ │ +----------------+-----------------+ +----------------+-----------------+ @@ -26,11 +26,11 @@ Both levels can be naturally expressed and efficiently executed using ROSE’s * | Train Model 1,2,...........m | | Train Model 1,2,...........m | +---------------+ +---------------+ +---------------+ +---------------+ | | | | - +-----------------------------+ +-----------------------------+ - | AL based on UQ WF 1 | | AL based on UQ WF 1 | - +-----------------------------+ +-----------------------------+ + +-----------------------------+ +-----------------------------+ + | AL based on UQ WF 1 | | AL based on UQ WF 1 | + +-----------------------------+ +-----------------------------+ ``` - + ### UQ-Driven Active Learning with Parallel Workflows @@ -71,8 +71,8 @@ async def prediction(*args): ```python # Defining the uncertainty quantification with a metric (PREDICTIVE_ENTROPY in this case) -@learner.uncertainty_quantification(uq_metric_name=PREDICTIVE_ENTROPY, - threshold=1.0, +@learner.uncertainty_quantification(uq_metric_name=PREDICTIVE_ENTROPY, + threshold=1.0, query_size=10) async def check_uq(*args): return f'{code_path}/check_uq.py'xs @@ -107,7 +107,7 @@ results = await learner.start( learner_names=PIPELINES, model_names=MODELS, learner_configs=learner_configs, - max_iter=ITERATIONS, + max_iter=ITERATIONS, num_predictions=NUM_PREDICTION ) ``` @@ -146,4 +146,4 @@ scorer = UQScorer(task_type="classification") print("Available metrics:", list(UQ_REGISTRY.keys())) UQ_METRIC_NAME='custom_uq' -``` \ No newline at end of file +``` diff --git a/examples/active_learn/advanced/active_learn.py b/examples/active_learn/advanced/active_learn.py index 3019f3d8..2532f4e5 100644 --- a/examples/active_learn/advanced/active_learn.py +++ b/examples/active_learn/advanced/active_learn.py @@ -1,26 +1,26 @@ # active_learn.py import json import pickle -from sklearn.feature_extraction.text import CountVectorizer -from sklearn.pipeline import Pipeline + import numpy as np + def active_learn(dataset_file, samples_file, model_file, updated_samples_file): # Load model - with open(model_file, 'rb') as f: + with open(model_file, "rb") as f: model = pickle.load(f) - + # Load dataset and current samples - with open(dataset_file, 'r') as f: + with open(dataset_file) as f: dataset = json.load(f) - with open(samples_file, 'r') as f: + with open(samples_file) as f: current_samples = json.load(f) - + # Extract remaining data current_texts = {s["text"] for s in current_samples} remaining_samples = [s for s in dataset if s["text"] not in current_texts] remaining_texts = [s["text"] for s in remaining_samples] - + if not remaining_texts: print("No remaining samples.") return @@ -28,16 +28,16 @@ def active_learn(dataset_file, samples_file, model_file, updated_samples_file): # Predict probabilities for remaining samples probs = model.predict_proba(remaining_texts) uncertainties = np.abs(probs[:, 0] - 0.5) - + # Select most uncertain samples uncertain_indices = uncertainties.argsort()[:10] new_samples = [remaining_samples[i] for i in uncertain_indices] - + # Update samples current_samples.extend(new_samples) - with open(updated_samples_file, 'w') as f: + with open(updated_samples_file, "w") as f: json.dump(current_samples, f) + if __name__ == "__main__": active_learn("dataset.json", "samples.json", "model.pkl", "samples.json") - diff --git a/examples/active_learn/advanced/advanced-tutorial.ipynb b/examples/active_learn/advanced/advanced-tutorial.ipynb deleted file mode 100644 index dede9f5f..00000000 --- a/examples/active_learn/advanced/advanced-tutorial.ipynb +++ /dev/null @@ -1,299 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "f4f14e3c-1e45-4c07-9d3c-d48be14978af", - "metadata": {}, - "source": [ - "# This notebook will show how to use the ROSE framework to run a custom Active Learning strategy" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f3300f9c-e101-40b8-910f-31c733ce0c10", - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "import sys\n", - "\n", - "from rose.learner import Learner\n", - "from rose.metrics import MODEL_ACCURACY\n", - "\n", - "from radical.asyncflow import WorkflowEngine\n", - "from radical.asyncflow import RadicalExecutionBackend" - ] - }, - { - "cell_type": "markdown", - "id": "fce35e4a-28e2-408f-b549-e6aff3031a3c", - "metadata": {}, - "source": [ - "## Custom Active Learning strategy:\n", - "In addition to the predefined workflows we saw in the `examples/basic/run_me.py`, the Active Learner API allows you to design custom active learning strategies tailored to your specific needs. This will allow you to define a flexible and iterative learning loop. For example, you could create a strategy that dynamically adjusts its behavior based on intermediate results, runs tasks in parallel, or incorporates additional utility tasks such as data cleaning or logging.\n", - "\n", - "The following example demonstrates how to create a hybrid workflow that alternates between parallel simulation tasks and training and active learning tasks and incorporates a custom stopping condition based on model performance:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "28c68da2-74dd-41ae-b0f4-cacd0adf0291", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Resource Engine started successfully\n", - "\n" - ] - } - ], - "source": [ - "engine = await RadicalExecutionBackend({'resource': 'local.localhost'})\n", - "asyncflow = await WorkflowEngine.create(engine)\n", - "learner = Learner(asyncflow)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "39d46460-093c-4f1c-9109-ef73fcea65ce", - "metadata": {}, - "outputs": [], - "source": [ - "code_path = f'{sys.executable} {os.getcwd()}'\n", - "\n", - "# Define and register the simulation task\n", - "@custom_acl.simulation_task\n", - "async def simulation(*args):\n", - " f'{code_path}/simulation.py'\n", - "\n", - "# Define and register the training task\n", - "@custom_acl.training_task\n", - "async def training(*args):\n", - " f'{code_path}/training.py'\n", - "\n", - "# Define and register the active learning task\n", - "@custom_acl.active_learn_task\n", - "async def active_learn(*args):\n", - " f'{code_path}/active_learn.py'\n", - "\n", - "# Defining the stop criterion with a metric (MSE in this case)\n", - "@custom_acl.as_stop_criterion(metric_name=MODEL_ACCURACY, threshold=0.99)\n", - "async def check_accuracy(*args):\n", - " f'{code_path}/check_accuracy.py'" - ] - }, - { - "cell_type": "markdown", - "id": "b8e0ac48-b60d-4eaf-aa00-66eb4bae60b5", - "metadata": {}, - "source": [ - "### Define your custom strategy and run it:\n", - "In this custom active-learn `start` method, we will submit 3 simulation tasks in parallel and let the training task handle their output. In this example, we are targeting the Accuracy metric of the trained model, and we will stop if we trach an accuracy of 99%" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d17a10d7-91fd-49fa-bd4c-31dcc68ffb41", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Registered task 'simulation' and id of 000000 with dependencies: []\n", - "Registered task 'simulation' and id of 000001 with dependencies: []\n", - "Registered task 'simulation' and id of 000002 with dependencies: []\n", - "Registered task 'training' and id of 000003 with dependencies: ['simulation', 'simulation', 'simulation']\n", - "Registered task 'active_learn' and id of 000004 with dependencies: ['training']\n", - "Registered task 'check_accuracy' and id of 000005 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000006 with dependencies: []\n", - "Registered task 'simulation' and id of 000007 with dependencies: []\n", - "Registered task 'simulation' and id of 000008 with dependencies: []\n", - "Registered task 'training' and id of 000009 with dependencies: ['simulation', 'simulation', 'simulation']\n", - "Registered task 'active_learn' and id of 000010 with dependencies: ['training']\n", - "Registered task 'check_accuracy' and id of 000011 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000012 with dependencies: []\n", - "Registered task 'simulation' and id of 000013 with dependencies: []\n", - "Registered task 'simulation' and id of 000014 with dependencies: []\n", - "Registered task 'training' and id of 000015 with dependencies: ['simulation', 'simulation', 'simulation']\n", - "Registered task 'active_learn' and id of 000016 with dependencies: ['training']\n", - "Registered task 'check_accuracy' and id of 000017 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000018 with dependencies: []\n", - "Registered task 'simulation' and id of 000019 with dependencies: []\n", - "Registered task 'simulation' and id of 000020 with dependencies: []\n", - "Registered task 'training' and id of 000021 with dependencies: ['simulation', 'simulation', 'simulation']\n", - "Registered task 'active_learn' and id of 000022 with dependencies: ['training']\n", - "Registered task 'check_accuracy' and id of 000023 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000024 with dependencies: []\n", - "Registered task 'simulation' and id of 000025 with dependencies: []\n", - "Registered task 'simulation' and id of 000026 with dependencies: []\n", - "Registered task 'training' and id of 000027 with dependencies: ['simulation', 'simulation', 'simulation']\n", - "Registered task 'active_learn' and id of 000028 with dependencies: ['training']\n", - "Registered task 'check_accuracy' and id of 000029 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000030 with dependencies: []\n", - "Registered task 'simulation' and id of 000031 with dependencies: []\n", - "Registered task 'simulation' and id of 000032 with dependencies: []\n", - "Registered task 'training' and id of 000033 with dependencies: ['simulation', 'simulation', 'simulation']\n", - "Registered task 'active_learn' and id of 000034 with dependencies: ['training']\n", - "Registered task 'check_accuracy' and id of 000035 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000036 with dependencies: []\n", - "Registered task 'simulation' and id of 000037 with dependencies: []\n", - "Registered task 'simulation' and id of 000038 with dependencies: []\n", - "Registered task 'training' and id of 000039 with dependencies: ['simulation', 'simulation', 'simulation']\n", - "Registered task 'active_learn' and id of 000040 with dependencies: ['training']\n", - "Registered task 'check_accuracy' and id of 000041 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000042 with dependencies: []\n", - "Registered task 'simulation' and id of 000043 with dependencies: []\n", - "Registered task 'simulation' and id of 000044 with dependencies: []\n", - "Registered task 'training' and id of 000045 with dependencies: ['simulation', 'simulation', 'simulation']\n", - "Registered task 'active_learn' and id of 000046 with dependencies: ['training']\n", - "Registered task 'check_accuracy' and id of 000047 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000048 with dependencies: []\n", - "Registered task 'simulation' and id of 000049 with dependencies: []\n", - "Registered task 'simulation' and id of 000050 with dependencies: []\n", - "Registered task 'training' and id of 000051 with dependencies: ['simulation', 'simulation', 'simulation']\n", - "Registered task 'active_learn' and id of 000052 with dependencies: ['training']\n", - "Registered task 'check_accuracy' and id of 000053 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000054 with dependencies: []\n", - "Registered task 'simulation' and id of 000055 with dependencies: []\n", - "Registered task 'simulation' and id of 000056 with dependencies: []\n", - "Registered task 'training' and id of 000057 with dependencies: ['simulation', 'simulation', 'simulation']\n", - "Registered task 'active_learn' and id of 000058 with dependencies: ['training']\n", - "Registered task 'check_accuracy' and id of 000059 with dependencies: ['active_learn']\n" - ] - } - ], - "source": [ - "async def start():\n", - " # 10 iterations of active learning\n", - " for acl_iter in range(10):\n", - " print(f'Starting Iteration-{acl_iter}')\n", - " simulations = []\n", - " for i in range(3):\n", - " # run 3 simulations in parallel\n", - " simulations.append(simulation())\n", - "\n", - " # wait for all simulation tasks to finish\n", - " await asyncio.gather(*simulations)\n", - " \n", - " # Now run training and active_learn\n", - " train = training(*simulations)\n", - " active = active_learn(simulations, train)\n", - "\n", - " should_stop, metric_val = await check_accuracy(active)\n", - " # check for the threshold to stop if accuracy is >= 99%\n", - " if should_stop:\n", - " print(f'Accuracy ({metric_val}) met the threshold, breaking...')\n", - " break\n", - "\n", - "# invoke the custom/user-defined start() method\n", - "await start()" - ] - }, - { - "cell_type": "markdown", - "id": "f22e4b87-6696-4709-9f2a-961bf57bef70", - "metadata": {}, - "source": [ - "Let's make sure to shutdown the resources." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a4f65c59-c6dd-4b14-a616-0a393ac1ec83", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Shutdown is triggered, terminating the resources gracefully\n" - ] - } - ], - "source": [ - "await learner.shutdown()" - ] - }, - { - "cell_type": "markdown", - "id": "42259319-259c-4766-b574-b5625212843e", - "metadata": {}, - "source": [ - "### Plot the output:\n", - "Now, let's visualize the output of our custom Active learning strategy across N iterations." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4591a422-39c9-4d61-9f0a-3d3d33481e44", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "Text(0.5, 1.0, 'ACC Values for Machine Learning Model')" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAA0oAAAIjCAYAAAA9VuvLAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAA9hAAAPYQGoP6dpAACBCUlEQVR4nO3dZ3hU1fr38d+kB0giLfTeQpem0kEREAsICtKkKAKC0ovnHP969FjoVUAUQakWQLEiIAhYsVGkN0FAOkmA0JL9vFjPJKRBEmZmTybfz3XlmpWZPXvfszNJ5t5rrXs5LMuyBAAAAABI5Gd3AAAAAADgbUiUAAAAACAFEiUAAAAASIFECQAAAABSIFECAAAAgBRIlAAAAAAgBRIlAAAAAEiBRAkAAAAAUiBRAgAAAIAUSJQAwIWaNWumZs2a2R1GujZt2qQGDRood+7ccjgc+uOPP+wOye2aNWumatWq3XS7gwcPyuFwaN68ee4Pyoc4HA69+OKLdofhUbfye166dGn17NnTpfEAcA8SJQAeM2PGDDkcDt1555033O748eMaPny4oqKilCtXLuXOnVt16tTR//73P507dy7V9suXL9d9992nAgUKKCgoSEWLFlXHjh31zTffpHuMZcuWyeFw6O233053m1WrVsnhcGjq1KkZfo3e7OrVq3r00Ud15swZTZo0SfPnz1epUqXcdrx169bJ4XDI4XBowYIFaW7TsGFDORyODCUyvuzFF1+Uw+HQqVOn7A4l23Amtg6HQ//73//S3KZr165yOBzKkyePh6MD4AsC7A4AQM6xcOFClS5dWj///LP27t2r8uXLp9pm06ZNatOmjc6fP69u3bqpTp06kqRffvlFr7/+utavX6+vv/5akmRZlnr37q158+apVq1aGjp0qAoXLqxjx45p+fLluueee/Tdd9+pQYMGqY5z//33KyIiQosWLdKTTz6ZZryLFi2Sv7+/HnvsMReeBfvs27dPf/31l9566610X7M7hISEaNGiRerWrVuy+w8ePKjvv/9eISEhHovlRkqVKqW4uDgFBgbaHUq2EhcXp4AA+z5OhISEaPHixfrPf/6T7P4LFy7ok08+8Zr3F4Dsh0QJgEccOHBA33//vZYtW6a+fftq4cKFeuGFF5Jtc+7cOT388MPy9/fX77//rqioqGSPv/LKK3rrrbcSv58wYYLmzZunwYMHa+LEiXI4HImP/fvf/9b8+fPT/QAXHBysRx55RHPnztXRo0dVtGjRZI9funRJy5cv17333qvIyMhbffle4cSJE5Kk2267zWX7vHDhgnLnzn3Dbdq0aaMVK1bo1KlTKlCgQOL9ixYtUqFChVShQgWdPXvWZTFllcPhyPEfqi9duqSgoCD5+WV8wInd56xNmzZatmyZNm/erJo1aybe/8knn+jKlStq3br1DXuXASA9DL0D4BELFy5U3rx5df/99+uRRx7RwoULU23z5ptv6siRI5o4cWKqJEmSChUqlHjVOC4uTq+99pqioqI0fvz4ZEmSU/fu3XXHHXekG1O3bt2UkJCgJUuWpHrs888/V3R0tLp27SpJmjt3ru6++25FRkYqODhYVapU0cyZM2/6uufNmyeHw6GDBw8mu985LG3dunXJ7v/pp5/UunVrRUREKFeuXGratKm+++67ZNvExsZq8ODBKl26tIKDgxUZGal7771Xv/32W7px9OzZU02bNpUkPfroo3I4HMnmWHzzzTdq3LixcufOrdtuu01t27bVjh07ku3DOTxs+/bt6tKli/LmzatGjRrd9By0bdtWwcHB+vDDD5Pdv2jRInXs2FH+/v6pnpOZ8/3ll1+qadOmCgsLU3h4uOrVq6dFixal2m779u1q3ry5cuXKpWLFimns2LHJHk9rjlLPnj2VJ08eHTlyRO3atVOePHlUsGBBDR8+XPHx8cmen5CQoMmTJ6tq1aoKCQlRoUKF1LdvX5cmgTt37tQjjzyifPnyKSQkRHXr1tWKFSuSbXPmzBkNHz5c1atXV548eRQeHq777rtPmzdvTrad8z24ZMkS/ec//1GxYsWUK1cuxcTEZOp1p5yj5Hyf7N27Vz179tRtt92miIgI9erVSxcvXkz23Li4OD377LMqUKCAwsLC9NBDD+nIkSOZmvdUv359lSlTJtXPfOHChWrdurXy5cuX5vNmzJihqlWrKjg4WEWLFtWAAQPSHNo7e/ZslStXTqGhobrjjju0YcOGNPd3+fJlvfDCCypfvryCg4NVokQJjRw5UpcvX87Q6wDgfUiUAHjEwoUL1b59ewUFBalz587as2ePNm3alGybFStWKDQ0VI888shN97dx40adOXNGXbp0SfODdkY0adJExYsXT/ND9aJFi5QrVy61a9dOkjRz5kyVKlVK//rXvzRhwgSVKFFCTz/9tN54440sHTst33zzjZo0aaKYmBi98MILevXVV3Xu3Dndfffd+vnnnxO369evn2bOnKkOHTpoxowZGj58uEJDQ1MlNtfr27ev/vWvf0mSnn32Wc2fP1///ve/JUmrV69Wq1atdOLECb344osaOnSovv/+ezVs2DBVgieZROvixYt69dVX1adPn5u+rly5cqlt27ZavHhx4n2bN2/Wn3/+qS5duqT5nIye73nz5un+++/XmTNn9Nxzz+n111/X7bffrq+++irZdmfPnlXr1q1Vs2ZNTZgwQVFRURo1apS+/PLLm8YfHx+vVq1aKX/+/Bo/fryaNm2qCRMmaPbs2cm269u3r0aMGKGGDRtqypQp6tWrlxYuXKhWrVrp6tWrNz3Ozfz555+66667tGPHDo0ePVoTJkxQ7ty51a5dOy1fvjxxu/379+vjjz/WAw88oIkTJ2rEiBHaunWrmjZtqqNHj6ba78svv6zPP/9cw4cP16uvvqqgoKBMve70dOzYUbGxsXrttdfUsWNHzZs3T//973+TbdOzZ09NmzZNbdq00ZgxYxQaGqr7778/0+emc+fOWrJkiSzLkiSdOnVKX3/9dbrvrxdffFEDBgxQ0aJFNWHCBHXo0EFvvvmmWrZsmexnNWfOHPXt21eFCxfW2LFj1bBhQz300EM6fPhwsv0lJCTooYce0vjx4/Xggw9q2rRpateunSZNmqROnTpl+vUA8BIWALjZL7/8YkmyVq1aZVmWZSUkJFjFixe3Bg0alGy7vHnzWjVr1szQPqdMmWJJspYvX35LsY0YMcKSZO3atSvxvujoaCskJMTq3Llz4n0XL15M9dxWrVpZZcuWTXZf06ZNraZNmyZ+P3fuXEuSdeDAgWTbrV271pJkrV271rIsc04qVKhgtWrVykpISEh23DJlylj33ntv4n0RERHWgAEDMv1ancf88MMPk91/++23W5GRkdbp06cT79u8ebPl5+dnPf7444n3vfDCC5akZOclo8f77LPPLIfDYR06dMiyLHPeneeuadOmVtWqVZM9NyPn+9y5c1ZYWJh15513WnFxccm2vf4cNm3a1JJkvffee4n3Xb582SpcuLDVoUOHxPsOHDhgSbLmzp2beF+PHj0sSdZLL72UbP+1atWy6tSpk/j9hg0bLEnWwoULk2331VdfpXl/Ss5ze/LkyXS3ueeee6zq1atbly5dSvY6GzRoYFWoUCHxvkuXLlnx8fHJnnvgwAErODg42etw/nzKli2b6nxn9HVblmVJsl544YVUr6V3797Jtnv44Yet/PnzJ37/66+/WpKswYMHJ9uuZ8+eqfaZFufPa9y4cda2bdssSdaGDRssy7KsN954w8qTJ4914cIFq0ePHlbu3LkTn3fixAkrKCjIatmyZbLzNH36dEuS9c4771iWZVlXrlyxIiMjrdtvv926fPly4nazZ8+2JCX7PZ8/f77l5+eXeHynWbNmWZKs7777LvG+UqVKWT169LjhawPgHehRAuB2CxcuVKFChdS8eXNJZqhOp06dtGTJkmTDeGJiYhQWFpahfcbExEhShrdPj7PAwPW9SkuXLtWlS5cSh91JUmhoaGI7Ojpap06dUtOmTbV//35FR0ffUgyS9Mcff2jPnj3q0qWLTp8+rVOnTunUqVO6cOGC7rnnHq1fv14JCQmSzByjn376Kc3egcw6duyY/vjjD/Xs2TPZEKUaNWro3nvv1RdffJHqOf369cv0cVq2bKl8+fIlXvVfsmSJOnfunO72GTnfq1atUmxsrEaPHp1qnkzKoZh58uRJVkwiKChId9xxh/bv35+h+FO+5saNGyd77ocffqiIiAjde++9iT+7U6dOqU6dOsqTJ4/Wrl2boeOk58yZM/rmm28Se2mc+z99+rRatWqlPXv26MiRI5LM/DvnHKP4+HidPn1aefLkUaVKldIcntmjR49k5zszr/tG0nru6dOnE393nb1+Tz/9dLLtnnnmmQzt/3pVq1ZVjRo1EnstFy1apLZt2ypXrlyptl29erWuXLmiwYMHJ5uL1adPH4WHh+vzzz+XZArInDhxQv369UvsZZNML1hERESyfX744YeqXLmyoqKikv387777bkm65Z8/AHuQKAFwq/j4eC1ZskTNmzfXgQMHtHfvXu3du1d33nmnjh8/rjVr1iRuGx4ertjY2AztNzw8XJIyvH16atSooWrVqiUbFrZo0SIVKFBArVq1Srzvu+++U4sWLRLn8BQsWDBxKJsrEqU9e/ZIMh9aCxYsmOzr7bff1uXLlxOPM3bsWG3btk0lSpTQHXfcoRdffDHDH15T+uuvvyRJlSpVSvVY5cqVE5O165UpUybTxwkMDNSjjz6qRYsWaf369Tp8+HC6w6KkjJ3vffv2SVKGSosXL148VfKUN2/eDM0fCgkJUcGCBW/43D179ig6OlqRkZGpfn7nz59PLKSRVXv37pVlWXr++edT7d9ZFMV5jISEBE2aNEkVKlRQcHCwChQooIIFC2rLli1pvlfT+3lm5HXfSMmSJVM9V1Li8//66y/5+fmlOn5a1TAzokuXLvrwww+1d+9eff/99+m+v9J7zwcFBals2bKJjztvK1SokGy7wMBAlS1bNtl9e/bs0Z9//pnqZ1OxYkVJuuWfPwB7UPUOgFt98803OnbsmJYsWZJm0YSFCxeqZcuWkqSoqCj98ccfunLlSrIruGlxFnvYunVr4jyirOrWrZtGjx6tX375RcWLF9fatWvVt2/fxIp5+/bt0z333KOoqChNnDhRJUqUUFBQkL744gtNmjQpsacnLWkVmZCUZiEASRo3bpxuv/32NJ/jXAumY8eOaty4sZYvX66vv/5a48aN05gxY7Rs2TLdd999mX35mZZe78PNdOnSRbNmzdKLL76omjVrqkqVKmludyvnOz3pzWOz/v+clqw893oJCQmKjIxMs0iJpFQJR2Y5X/Pw4cOTJfDXcyYYr776qp5//nn17t1bL7/8svLlyyc/Pz8NHjw4zXOX3s8zq3P/bvb8jJzzrOjcubOee+459enTR/nz50/8u+IJCQkJql69uiZOnJjm4yVKlPBYLABch0QJgFstXLhQkZGRaRY9WLZsmZYvX65Zs2YpNDRUDz74oH744QctXbr0hsOyJKlRo0bKmzevFi9erH/961+39KHO+QFr0aJFKlWqlOLj45MNu/v00091+fJlrVixItlV8owMp3FeRU9ZTct5tdqpXLlykkxPWYsWLW663yJFiujpp5/W008/rRMnTqh27dp65ZVXMp0oORec3bVrV6rHdu7cqQIFCty0/HdGNWrUSCVLltS6des0ZsyYdLfL6Pl2nrNt27ZluRfCVcqVK6fVq1erYcOGWU4kb8TZgxEYGHjT98dHH32k5s2ba86cOcnuP3fuXLLy7HYrVaqUEhISdODAgWS9Nnv37s3S/kqWLKmGDRtq3bp16t+/f7pLA1z/nr++Z+jKlSs6cOBA4vl1brdnz57EIXSSWbj5wIEDyUqRlytXTps3b9Y999yT7sURANkPQ+8AuE1cXJyWLVumBx54QI888kiqr4EDByo2NjaxvHG/fv1UpEgRDRs2TLt37061vxMnTuh///ufJFNJbdSoUdqxY4dGjRqV5lXqBQsWJKsWl56SJUuqcePGev/997VgwQKVKVMm2SK1ziTs+mNER0dr7ty5N92388P8+vXrE++Lj49PVTmsTp06KleunMaPH6/z58+n2s/JkycTn5ty+FRkZKSKFi2apTLERYoU0e2336533303WTK3bds2ff3112rTpk2m95keh8OhqVOn6oUXXlD37t3T3S6j57tly5YKCwvTa6+9pkuXLiV7zF29Funp2LGj4uPj9fLLL6d67Nq1a2mWnc6MyMhINWvWTG+++aaOHTuW6nHn+0My5y/l6//www8T5zB5C2fP2IwZM5LdP23atCzv83//+59eeOGFG85zatGihYKCgjR16tRk52nOnDmKjo5OrLpXt25dFSxYULNmzdKVK1cSt5s3b16qn2fHjh115MiRZOu8OcXFxaUavgoge6BHCYDbrFixQrGxsXrooYfSfPyuu+5SwYIFtXDhQnXq1El58+bV8uXL1aZNG91+++3q1q2b6tSpI0n67bfftHjxYtWvXz/x+SNGjNCff/6pCRMmaO3atXrkkUdUuHBh/fPPP/r444/1888/6/vvv89QrN26ddNTTz2lo0ePJpbNdmrZsqWCgoL04IMPqm/fvjp//rzeeustRUZGpvmh9XpVq1bVXXfdpeeee05nzpxJLGhw7dq1ZNv5+fnp7bff1n333aeqVauqV69eKlasmI4cOaK1a9cqPDxcn376qWJjY1W8eHE98sgjqlmzpvLkyaPVq1dr06ZNmjBhQoZea0rjxo3Tfffdp/r16+uJJ55QXFycpk2bpoiIiAyvZZNRbdu2Vdu2bW+4TUbPd3h4uCZNmqQnn3xS9erVS1zbafPmzbp48aLeffddl8Z+I02bNlXfvn312muv6Y8//lDLli0VGBioPXv26MMPP9SUKVMyVPZ+4sSJqQoQ+Pn56V//+pfeeOMNNWrUSNWrV1efPn1UtmxZHT9+XD/88IP+/vvvxHWSHnjgAb300kvq1auXGjRooK1bt2rhwoWp5tXYrU6dOurQoYMmT56s06dP66677tK3336beJEkKz0zTZs2TVwvLD0FCxbUc889p//+979q3bq1HnroIe3atUszZsxQvXr1Eot+BAYG6n//+5/69u2ru+++W506ddKB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d955p37++We7Q4IPeu2111SvXj2FhYUpMjJS7dq1065du+wOCznI66+/LofDocGDB9sdCnzYkSNH1K1bN+XPn1+hoaGqXr26fvnlF7vDgg+Kj4/X888/rzJlyig0NFTlypXTyy+/LOqh+T4SJQ95//33NXToUL3wwgv67bffVLNmTbVq1UonTpywOzT4mG+//VYDBgzQjz/+qFWrVunq1atq2bKlLly4YHdoyAE2bdqkN998UzVq1LA7FPiws2fPqmHDhgoMDNSXX36p7du3a8KECcqbN6/docEHjRkzRjNnztT06dO1Y8cOjRkzRmPHjtW0adPsDg1uRnlwD7nzzjtVr149TZ8+XZKUkJCgEiVK6JlnntHo0aNtjg6+7OTJk4qMjNS3336rJk2a2B0OfNj58+dVu3ZtzZgxQ//73/90++23a/LkyXaHBR80evRofffdd9qwYYPdoSAHeOCBB1SoUCHNmTMn8b4OHTooNDRUCxYssDEyuBs9Sh5w5coV/frrr2rRokXifX5+fmrRooV++OEHGyNDThAdHS1Jypcvn82RwNcNGDBA999/f7K/dYA7rFixQnXr1tWjjz6qyMhI1apVS2+99ZbdYcFHNWjQQGvWrNHu3bslSZs3b9bGjRt133332RwZ3C3A7gByglOnTik+Pl6FChVKdn+hQoW0c+dOm6JCTpCQkKDBgwerYcOGqlatmt3hwIctWbJEv/32mzZt2mR3KMgB9u/fr5kzZ2ro0KH617/+pU2bNunZZ59VUFCQevToYXd48DGjR49WTEyMoqKi5O/vr/j4eL3yyivq2rWr3aHBzUiUAB82YMAAbdu2TRs3brQ7FPiww4cPa9CgQVq1apVCQkLsDgc5QEJCgurWratXX31VklSrVi1t27ZNs2bNIlGCy33wwQdauHChFi1apKpVq+qPP/7Q4MGDVbRoUd5vPo5EyQMKFCggf39/HT9+PNn9x48fV+HChW2KCr5u4MCB+uyzz7R+/XoVL17c7nDgw3799VedOHFCtWvXTrwvPj5e69ev1/Tp03X58mX5+/vbGCF8TZEiRVSlSpVk91WuXFlLly61KSL4shEjRmj06NF67LHHJEnVq1fXX3/9pddee41EyccxR8kDgoKCVKdOHa1ZsybxvoSEBK1Zs0b169e3MTL4IsuyNHDgQC1fvlzffPONypQpY3dI8HH33HOPtm7dqj/++CPxq27duuratav++OMPkiS4XMOGDVMte7B7926VKlXKpojgyy5evCg/v+Qfmf39/ZWQkGBTRPAUepQ8ZOjQoerRo4fq1q2rO+64Q5MnT9aFCxfUq1cvu0ODjxkwYIAWLVqkTz75RGFhYfrnn38kSREREQoNDbU5OviisLCwVHPgcufOrfz58zM3Dm4xZMgQNWjQQK+++qo6duyon3/+WbNnz9bs2bPtDg0+6MEHH9Qrr7yikiVLqmrVqvr99981ceJE9e7d2+7Q4GaUB/eg6dOna9y4cfrnn390++23a+rUqbrzzjvtDgs+xuFwpHn/3Llz1bNnT88GgxyrWbNmlAeHW3322Wd67rnntGfPHpUpU0ZDhw5Vnz597A4LPig2NlbPP/+8li9frhMnTqho0aLq3Lmz/u///k9BQUF2hwc3IlECAAAAgBSYowQAAAAAKZAoAQAAAEAKJEoAAAAAkAKJEgAAAACkQKIEAAAAACmQKAEAAABACiRKAAAAAJACiRIAAAAApECiBAA+YN68ebrtttvsDgM3sW7dOjkcDp07d87uUDLE4XDo448/tjsMALAFiRIA2OCHH36Qv7+/7r///kw/t3Tp0po8eXKy+zp16qTdu3e7KLq0ZecP+QcPHpTD4dAff/zhseM3a9ZMgwcPTnZfgwYNdOzYMUVERLj12D179lS7du3cegwA8HUkSgBggzlz5uiZZ57R+vXrdfTo0VveX2hoqCIjI10QmXe5cuWK3SGkcvXq1Sw/NygoSIULF5bD4XBhRAAAdyBRAgAPO3/+vN5//331799f999/v+bNm5dqm08//VT16tVTSEiIChQooIcffliS6aX466+/NGTIEDkcjsQP3NcPvdu9e7ccDod27tyZbJ+TJk1SuXLlEr/ftm2b7rvvPuXJk0eFChVS9+7dderUqSy/rsuXL2v48OEqVqyYcufOrTvvvFPr1q1LfPz06dPq3LmzihUrply5cql69epavHhxsn00a9ZMAwcO1ODBg1WgQAG1atUqsSdrzZo1qlu3rnLlyqUGDRpo165dGY6tTJkykqRatWrJ4XCoWbNmiY+9/fbbqly5skJCQhQVFaUZM2YkPubsiXr//ffVtGlThYSEaOHChTd9LT179tS3336rKVOmJP6cDh48mGav3NKlS1W1alUFBwerdOnSmjBhQrLYS5curVdffVW9e/dWWFiYSpYsqdmzZ2f4tTvP67PPPquRI0cqX758Kly4sF588cVk2+zZs0dNmjRRSEiIqlSpolWrVqXaz+HDh9WxY0fddtttypcvn9q2bauDBw9Kknbu3KlcuXJp0aJFidt/8MEHCg0N1fbt2zMVLwB4AxIlAPCwDz74QFFRUapUqZK6deumd955R5ZlJT7++eef6+GHH1abNm30+++/a82aNbrjjjskScuWLVPx4sX10ksv6dixYzp27Fiq/VesWFF169bVwoULk92/cOFCdenSRZJ07tw53X333apVq5Z++eUXffXVVzp+/Lg6duyY5dc1cOBA/fDDD1qyZIm2bNmiRx99VK1bt9aePXskSZcuXVKdOnX0+eefa9u2bXrqqafUvXt3/fzzz8n28+677yooKEjfffedZs2alXj/v//9b02YMEG//PKLAgIC1Lt37wzH5jzG6tWrdezYMS1btizxnPzf//2fXnnlFe3YsUOvvvqqnn/+eb377rvJnj969GgNGjRIO3bsUKtWrW76WqZMmaL69eurT58+iT+nEiVKpIrr119/VceOHfXYY49p69atevHFF/X888+nSp4nTJigunXr6vfff9fTTz+t/v37ZypRlMx5zZ07t3766SeNHTtWL730UmIylJCQoPbt2ysoKEg//fSTZs2apVGjRiV7/tWrV9WqVSuFhYVpw4YN+u6775QnTx61bt1aV65cUVRUlMaPH6+nn35ahw4d0t9//61+/fppzJgxqlKlSqZiBQCvYAEAPKpBgwbW5MmTLcuyrKtXr1oFChSw1q5dm/h4/fr1ra5du6b7/FKlSlmTJk1Kdt/cuXOtiIiIxO8nTZpklStXLvH7Xbt2WZKsHTt2WJZlWS+//LLVsmXLZPs4fPiwJcnatWtXmsddu3atJck6e/Zsqsf++usvy9/f3zpy5Eiy+++55x7rueeeS/e13H///dawYcMSv2/atKlVq1atNI+7evXqxPs+//xzS5IVFxeX7r4lWcuXL7csy7IOHDhgSbJ+//33ZNuUK1fOWrRoUbL7Xn75Zat+/frJnuf8ed1IWq9l0KBBab4W5zns0qWLde+99ybbZsSIEVaVKlUSvy9VqpTVrVu3xO8TEhKsyMhIa+bMmenG0qNHD6tt27bJYmnUqFGyberVq2eNGjXKsizL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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "import matplotlib.pyplot as plt\n", - "# Convert string values to floats\n", - "mse_values = custom_acl.get_metric_results()\n", - "mse_values = [float(acc.strip()) * 100 for acc in mse_values]\n", - "\n", - "# Plot the MSE values\n", - "plt.figure(figsize=(10, 6))\n", - "plt.plot(mse_values, marker='o', color='b', linestyle='-', markersize=6)\n", - "\n", - "# Add labels and title\n", - "plt.xlabel('Active Learn Iteration Index')\n", - "plt.ylabel('ACC Value')\n", - "plt.title('ACC Values for Machine Learning Model')" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/examples/active_learn/advanced/check_accuracy.py b/examples/active_learn/advanced/check_accuracy.py index db7c83ac..53677a0d 100644 --- a/examples/active_learn/advanced/check_accuracy.py +++ b/examples/active_learn/advanced/check_accuracy.py @@ -1,24 +1,26 @@ # check_stop.py import json import pickle + from sklearn.metrics import accuracy_score + def check_stop(samples_file, model_file): # Load samples - with open(samples_file, 'r') as f: + with open(samples_file) as f: samples = json.load(f) texts = [s["text"] for s in samples] labels = [s["label"] for s in samples] # Load model - with open(model_file, 'rb') as f: + with open(model_file, "rb") as f: model = pickle.load(f) - + # Evaluate model predictions = model.predict(texts) accuracy = accuracy_score(labels, predictions) print(accuracy) - + if __name__ == "__main__": check_stop("samples.json", "model.pkl") diff --git a/examples/active_learn/advanced/run_me.py b/examples/active_learn/advanced/run_me.py index a55686c8..eb208b6c 100644 --- a/examples/active_learn/advanced/run_me.py +++ b/examples/active_learn/advanced/run_me.py @@ -1,49 +1,48 @@ +import asyncio import os import sys -import asyncio +from concurrent.futures import ThreadPoolExecutor + +from radical.asyncflow import WorkflowEngine +from rhapsody.backends import ConcurrentExecutionBackend from rose.learner import Learner from rose.metrics import MODEL_ACCURACY -from radical.asyncflow import WorkflowEngine -from radical.asyncflow import ConcurrentExecutionBackend - -from concurrent.futures import ThreadPoolExecutor async def custom_al(): - engine = await ConcurrentExecutionBackend(ThreadPoolExecutor()) asyncflow = await WorkflowEngine.create(engine) learner = Learner(asyncflow) - code_path = f'{sys.executable} {os.getcwd()}' + code_path = f"{sys.executable} {os.getcwd()}" # Define and register the simulation task @learner.simulation_task async def simulation(*args): - return f'{code_path}/simulation.py' + return f"{code_path}/simulation.py" # Define and register the training task @learner.training_task async def training(*args): - return f'{code_path}/training.py' + return f"{code_path}/training.py" # Define and register the active learning task @learner.active_learn_task async def active_learn(*args): - return f'{code_path}/active_learn.py' + return f"{code_path}/active_learn.py" # Defining the stop criterion with a metric (MSE in this case) @learner.as_stop_criterion(metric_name=MODEL_ACCURACY, threshold=0.99) async def check_accuracy(*args): - return f'{code_path}/check_accuracy.py' + return f"{code_path}/check_accuracy.py" async def start(): # 10 iterations of active learn for acl_iter in range(10): - print(f'Starting Iteration-{acl_iter}') - sim = simulation() # <-- this returns a future - train = training(sim) # <-- wait for sim task first - active = active_learn(sim, train) # <-- wait for sim and train + print(f"Starting Iteration-{acl_iter}") + sim = simulation() # <-- this returns a future + train = training(sim) # <-- wait for sim task first + active = active_learn(sim, train) # <-- wait for sim and train # wait for active learn task and obtain result once done should_stop, metric_val = await check_accuracy(active) @@ -55,5 +54,6 @@ async def start(): await start() await learner.shutdown() + if __name__ == "__main__": asyncio.run(custom_al()) diff --git a/examples/active_learn/advanced/simulation.py b/examples/active_learn/advanced/simulation.py index f25a68d5..f0d1588a 100644 --- a/examples/active_learn/advanced/simulation.py +++ b/examples/active_learn/advanced/simulation.py @@ -1,27 +1,34 @@ # simulation.py -import logging import json -from datasets import load_dataset +import logging import random +from datasets import load_dataset + + def simulate(output_file, sample_size=100): logging.basicConfig(level=logging.INFO) logging.info("Loading Rotten Tomatoes dataset...") - + raw_dataset = load_dataset("rotten_tomatoes") - dataset = [{"text": text, "label": label} for text, label in zip(raw_dataset["train"]["text"], raw_dataset["train"]["label"])] - + dataset = [ + {"text": text, "label": label} + for text, label in zip( + raw_dataset["train"]["text"], raw_dataset["train"]["label"], strict=False + ) + ] + # Save the entire dataset to file - with open("dataset.json", 'w') as f: + with open("dataset.json", "w") as f: json.dump(dataset, f) - + # Sample random data samples = random.sample(dataset, sample_size) - with open(output_file, 'w') as f: + with open(output_file, "w") as f: json.dump(samples, f) - + logging.info(f"Selected {sample_size} samples.") + if __name__ == "__main__": simulate("samples.json") - diff --git a/examples/active_learn/advanced/training.py b/examples/active_learn/advanced/training.py index 51c66f93..0f23a088 100644 --- a/examples/active_learn/advanced/training.py +++ b/examples/active_learn/advanced/training.py @@ -1,26 +1,28 @@ # training.py import json +import pickle + from sklearn.feature_extraction.text import CountVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import make_pipeline -import pickle + def train_model(samples_file, model_file): # Load samples - with open(samples_file, 'r') as f: + with open(samples_file) as f: samples = json.load(f) - + texts = [s["text"] for s in samples] labels = [s["label"] for s in samples] - + # Create pipeline: Vectorizer + Logistic Regression model = make_pipeline(CountVectorizer(), LogisticRegression(max_iter=1000)) model.fit(texts, labels) - + # Save model to file - with open(model_file, 'wb') as f: + with open(model_file, "wb") as f: pickle.dump(model, f) + if __name__ == "__main__": train_model("samples.json", "model.pkl") - diff --git a/examples/active_learn/algorithm_selector/active_1.py b/examples/active_learn/algorithm_selector/active_1.py index cedcc97e..26d5cb5e 100644 --- a/examples/active_learn/algorithm_selector/active_1.py +++ b/examples/active_learn/algorithm_selector/active_1.py @@ -1,21 +1,21 @@ # acl.py import pickle + import numpy as np -from sklearn.linear_model import LinearRegression -from sklearn.metrics import mean_squared_error -import time + def complicated_function(x): return ( - 0.3 * np.sin(1.5 * np.pi * x**2) + - 0.2 * np.cos(2 * np.pi * x**3) + - 0.5 * np.exp(-0.5 * x) + - 0.1 * np.tanh(0.2 * (x - 0.5)) + - 0.3 * (x**3) - ) - -def acl(input_file='train_output.pkl', output_file='acl_output.pkl'): - with open(input_file, 'rb') as f: + 0.3 * np.sin(1.5 * np.pi * x**2) + + 0.2 * np.cos(2 * np.pi * x**3) + + 0.5 * np.exp(-0.5 * x) + + 0.1 * np.tanh(0.2 * (x - 0.5)) + + 0.3 * (x**3) + ) + + +def acl(input_file="train_output.pkl", output_file="acl_output.pkl"): + with open(input_file, "rb") as f: labeled_data, model = pickle.load(f) X = np.random.uniform(low=0.0, high=1.0, size=500) @@ -27,10 +27,11 @@ def acl(input_file='train_output.pkl', output_file='acl_output.pkl'): uncertain_indices = uncertainty.argsort()[-100:] # Top 100 most uncertain samples X_selected = X[uncertain_indices] - with open(output_file, 'wb') as f: + with open(output_file, "wb") as f: pickle.dump((labeled_data, X_selected), f) return output_file + if __name__ == "__main__": acl() # Running the active learning task diff --git a/examples/active_learn/algorithm_selector/active_2.py b/examples/active_learn/algorithm_selector/active_2.py index 517d19ff..dfcdfd1d 100644 --- a/examples/active_learn/algorithm_selector/active_2.py +++ b/examples/active_learn/algorithm_selector/active_2.py @@ -1,28 +1,29 @@ # acl.py import pickle + import numpy as np -from sklearn.linear_model import LinearRegression -from sklearn.metrics import mean_squared_error -import time + def complicated_function(x): return ( - 0.3 * np.sin(1.5 * np.pi * x**2) + - 0.2 * np.cos(2 * np.pi * x**3) + - 0.5 * np.exp(-0.5 * x) + - 0.1 * np.tanh(0.2 * (x - 0.5)) + - 0.3 * (x**3) - ) - -def acl(input_file='train_output.pkl', output_file='acl_output.pkl'): - with open(input_file, 'rb') as f: + 0.3 * np.sin(1.5 * np.pi * x**2) + + 0.2 * np.cos(2 * np.pi * x**3) + + 0.5 * np.exp(-0.5 * x) + + 0.1 * np.tanh(0.2 * (x - 0.5)) + + 0.3 * (x**3) + ) + + +def acl(input_file="train_output.pkl", output_file="acl_output.pkl"): + with open(input_file, "rb") as f: labeled_data, model = pickle.load(f) X_selected = np.random.uniform(low=0.0, high=1.0, size=100) - with open(output_file, 'wb') as f: + with open(output_file, "wb") as f: pickle.dump((labeled_data, X_selected), f) return output_file + if __name__ == "__main__": acl() # Running the active learning task diff --git a/examples/active_learn/algorithm_selector/check_mse.py b/examples/active_learn/algorithm_selector/check_mse.py index 09ef3010..f9b83952 100644 --- a/examples/active_learn/algorithm_selector/check_mse.py +++ b/examples/active_learn/algorithm_selector/check_mse.py @@ -1,22 +1,23 @@ # check.py -import sys import pickle -import numpy as np +import numpy as np from sklearn.metrics import mean_squared_error + def complicated_function(x): return ( - 0.3 * np.sin(1.5 * np.pi * x**2) + - 0.2 * np.cos(2 * np.pi * x**3) + - 0.5 * np.exp(-0.5 * x) + - 0.1 * np.tanh(0.2 * (x - 0.5)) + - 0.3 * (x**3) - ) - -def check(input_file='train_output.pkl'): + 0.3 * np.sin(1.5 * np.pi * x**2) + + 0.2 * np.cos(2 * np.pi * x**3) + + 0.5 * np.exp(-0.5 * x) + + 0.1 * np.tanh(0.2 * (x - 0.5)) + + 0.3 * (x**3) + ) + + +def check(input_file="train_output.pkl"): # Load the model after active learning - with open(input_file, 'rb') as f: + with open(input_file, "rb") as f: labeled_data, model = pickle.load(f) X_eval = np.random.uniform(low=0.0, high=1.0, size=500) @@ -26,5 +27,6 @@ def check(input_file='train_output.pkl'): mse_eval = mean_squared_error(y_eval, y_pred_eval) print(mse_eval) + if __name__ == "__main__": check() # Running the check task diff --git a/examples/active_learn/algorithm_selector/run_me.py b/examples/active_learn/algorithm_selector/run_me.py index e75ffd83..ff7b062a 100644 --- a/examples/active_learn/algorithm_selector/run_me.py +++ b/examples/active_learn/algorithm_selector/run_me.py @@ -2,46 +2,48 @@ import os import sys -from radical.asyncflow import RadicalExecutionBackend, WorkflowEngine +from radical.asyncflow import WorkflowEngine +from rhapsody.backends import RadicalExecutionBackend from rose.al.selector import AlgorithmSelector from rose.metrics import MEAN_SQUARED_ERROR_MSE async def select_algorithm(): - engine = await RadicalExecutionBackend({'resource': 'local.localhost'}) + engine = await RadicalExecutionBackend({"resource": "local.localhost"}) asyncflow = await WorkflowEngine.create(engine) - als = AlgorithmSelector(asyncflow) + also = AlgorithmSelector(asyncflow) - code_path = f'{sys.executable} {os.getcwd()}' + code_path = f"{sys.executable} {os.getcwd()}" # Define and register the simulation task - @als.simulation_task + @also.simulation_task async def simulation(*args): - return f'{code_path}/sim.py' + return f"{code_path}/sim.py" # Define and register the training task - @als.training_task + @also.training_task async def training(*args): - return f'{code_path}/train.py' + return f"{code_path}/train.py" # Define and register Multiple AL tasks - @als.active_learn_task(name='algo_1') + @also.active_learn_task(name="algo_1") async def active_learn_1(*args): - return f'{code_path}/active_1.py' + return f"{code_path}/active_1.py" - @als.active_learn_task(name='algo_2') + @also.active_learn_task(name="algo_2") async def active_learn_2(*args): - return f'{code_path}/active_2.py' + return f"{code_path}/active_2.py" # Defining the stop criterion with a metric (MSE in this case) - @als.as_stop_criterion(metric_name=MEAN_SQUARED_ERROR_MSE, threshold=0.01) + @also.as_stop_criterion(metric_name=MEAN_SQUARED_ERROR_MSE, threshold=0.01) async def check_mse(*args): - return f'{code_path}/check_mse.py' + return f"{code_path}/check_mse.py" # Start the learning process - await als.start(max_iter=4) - await als.shutdown() + await also.start(max_iter=4) + await also.shutdown() + if __name__ == "__main__": asyncio.run(select_algorithm()) diff --git a/examples/active_learn/algorithm_selector/sim.py b/examples/active_learn/algorithm_selector/sim.py index 1978c5da..b45f4302 100644 --- a/examples/active_learn/algorithm_selector/sim.py +++ b/examples/active_learn/algorithm_selector/sim.py @@ -1,22 +1,25 @@ # sim.py -import numpy as np -import pickle import os +import pickle + +import numpy as np + def complicated_function(x): return ( - 0.3 * np.sin(1.5 * np.pi * x**2) + - 0.2 * np.cos(2 * np.pi * x**3) + - 0.5 * np.exp(-0.5 * x) + - 0.1 * np.tanh(0.2 * (x - 0.5)) + - 0.3 * (x**3) - ) - -def sim(input_file='acl_output.pkl', output_file='sim_output.pkl'): + 0.3 * np.sin(1.5 * np.pi * x**2) + + 0.2 * np.cos(2 * np.pi * x**3) + + 0.5 * np.exp(-0.5 * x) + + 0.1 * np.tanh(0.2 * (x - 0.5)) + + 0.3 * (x**3) + ) + + +def sim(input_file="acl_output.pkl", output_file="sim_output.pkl"): labeled_data = None unlabeled_data = np.linspace(0, 1, 100) if os.path.isfile(input_file): - with open(input_file, 'rb') as f: + with open(input_file, "rb") as f: (labeled_data, unlabeled_data) = pickle.load(f) y = complicated_function(unlabeled_data) @@ -29,9 +32,9 @@ def sim(input_file='acl_output.pkl', output_file='sim_output.pkl'): labeled_data = (x, y) else: labeled_data = (unlabeled_data, y) - + print("labeled_data = ", labeled_data) - with open(output_file, 'wb') as f: + with open(output_file, "wb") as f: pickle.dump(labeled_data, f) print(f"Simulation completed. Data saved to {output_file}") diff --git a/examples/active_learn/algorithm_selector/train.py b/examples/active_learn/algorithm_selector/train.py index 1076c2c5..53035f8c 100644 --- a/examples/active_learn/algorithm_selector/train.py +++ b/examples/active_learn/algorithm_selector/train.py @@ -1,33 +1,36 @@ # train.py import pickle -from sklearn.neural_network import MLPRegressor + from sklearn.metrics import mean_squared_error +from sklearn.neural_network import MLPRegressor -def train(input_file='sim_output.pkl', output_file='train_output.pkl'): - with open(input_file, 'rb') as f: + +def train(input_file="sim_output.pkl", output_file="train_output.pkl"): + with open(input_file, "rb") as f: (X_labeled, y_labeled) = pickle.load(f) - - X_input = X_labeled.reshape(-1, 1) + + X_input = X_labeled.reshape(-1, 1) model = MLPRegressor( - hidden_layer_sizes=(32, 32, 16, 16), - activation='relu', - solver='adam', - max_iter=1000, - learning_rate='adaptive', - random_state=42 + hidden_layer_sizes=(32, 32, 16, 16), + activation="relu", + solver="adam", + max_iter=1000, + learning_rate="adaptive", + random_state=42, ) model.fit(X_input, y_labeled) - + y_pred = model.predict(X_input) mse = mean_squared_error(y_labeled, y_pred) - + print(f"Training completed. MSE: {mse:.4f}") - with open(output_file, 'wb') as f: + with open(output_file, "wb") as f: pickle.dump(((X_labeled, y_labeled), model), f) - + print(f"Model saved to {output_file}") return output_file, mse + if __name__ == "__main__": train() # Running the training task diff --git a/examples/active_learn/basic/active.py b/examples/active_learn/basic/active.py index ce59535d..93f0a7f8 100644 --- a/examples/active_learn/basic/active.py +++ b/examples/active_learn/basic/active.py @@ -1,25 +1,30 @@ # acl.py import pickle + import numpy as np -from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error -def acl(input_file='train_output.pkl', output_file='acl_output.pkl'): + +def acl(input_file="train_output.pkl", output_file="acl_output.pkl"): # Load model and data - with open(input_file, 'rb') as f: + with open(input_file, "rb") as f: model = pickle.load(f) - - with open('sim_output.pkl', 'rb') as f: + + with open("sim_output.pkl", "rb") as f: (X_labeled, y_labeled), X_unlabeled = pickle.load(f) # Predict on the unlabeled data to find the most uncertain samples y_pred_unlabeled = model.predict(X_unlabeled) - uncertainty = np.abs(y_pred_unlabeled - np.mean(y_labeled)) # Example of uncertainty: deviation from mean label + uncertainty = np.abs( + y_pred_unlabeled - np.mean(y_labeled) + ) # Example of uncertainty: deviation from mean label # Select the most uncertain data points (e.g., top 10% most uncertain) uncertain_indices = uncertainty.argsort()[-10:] # Top 10 most uncertain samples X_selected = X_unlabeled[uncertain_indices] - y_selected = 2 * X_selected + 1 + np.random.normal(0, 0.1, X_selected.shape) # Simulate labels for selected data + y_selected = ( + 2 * X_selected + 1 + np.random.normal(0, 0.1, X_selected.shape) + ) # Simulate labels for selected data # Ensure X_selected is 2D (flatten if necessary) if X_selected.ndim == 3: @@ -39,14 +44,15 @@ def acl(input_file='train_output.pkl', output_file='acl_output.pkl'): # Evaluate retrained model y_pred = model.predict(X_labeled) mse = mean_squared_error(y_labeled, y_pred) - + print(f"Active Learning completed. MSE: {mse:.4f}") # Save the updated model and the new labeled data - with open(output_file, 'wb') as f: + with open(output_file, "wb") as f: pickle.dump(model, f) return output_file, mse + if __name__ == "__main__": acl() # Running the active learning task diff --git a/examples/active_learn/basic/basic-tutorial.ipynb b/examples/active_learn/basic/basic-tutorial.ipynb deleted file mode 100644 index 1fa1b1d5..00000000 --- a/examples/active_learn/basic/basic-tutorial.ipynb +++ /dev/null @@ -1,314 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "f4f14e3c-1e45-4c07-9d3c-d48be14978af", - "metadata": {}, - "source": [ - "# This notebook will show how to use the ROSE framework to run an Active Learning workflow" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6f5a2778-4eef-415a-b9ec-65f5d28bfe8e", - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "import sys\n", - "\n", - "from rose.metrics import MEAN_SQUARED_ERROR_MSE\n", - "from rose.al.active_learner import SequentialActiveLearner\n", - "\n", - "from radical.asyncflow import WorkflowEngine\n", - "from radical.asyncflow import RadicalExecutionBackend" - ] - }, - { - "cell_type": "markdown", - "id": "a81db849-c634-4971-8dce-1f10b5eb003e", - "metadata": {}, - "source": [ - "### Let us first declare the resource engine for our active learning tasks.\n", - "We will ask for 30 minutes, and the target resources will be local, which means it will run on the user's machine.\n", - "\n", - "Next, we define the active learner and assign the resource engine." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8c44487b-0029-4b2e-b325-6131b2ed2f6f", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Resource Engine started successfully\n", - "\n" - ] - } - ], - "source": [ - "engine = await RadicalExecutionBackend({'resource': 'local.localhost'})\n", - "asyncflow = await WorkflowEngine.create(engine)\n", - "\n", - "acl = SequentialActiveLearner(asyncflow)\n", - "\n", - "code_path = f'{sys.executable} {os.getcwd()}'" - ] - }, - { - "cell_type": "markdown", - "id": "e7d0172d-ffc5-4d5d-bec0-4d4805b680da", - "metadata": {}, - "source": [ - "### Now, let us define our active learning tasks: simulation, training, and active learning" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c1184cb7-e140-4102-8e25-c2a0c8045b2d", - "metadata": {}, - "outputs": [], - "source": [ - "# Define and register the simulation task\n", - "@acl.simulation_task\n", - "async def simulation(*args):\n", - " return f'{code_path}/sim.py'\n", - "\n", - "# Define and register the training task\n", - "@acl.training_task\n", - "async def training(*args):\n", - " return f'{code_path}/train.py'\n", - "\n", - "# Define and register the active learning task\n", - "@acl.active_learn_task\n", - "async def active_learn(*args):\n", - " return f'{code_path}/active.py'" - ] - }, - { - "cell_type": "markdown", - "id": "83a8ba45-9e28-46c8-8630-b0ad997fdb60", - "metadata": {}, - "source": [ - "Optionally, we can define a stop criterion, which will be invoked on every iteration of the Active learning loop and break the iterations if it is satisfied." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "26b89932-86ae-4fc7-81e3-39b57522430d", - "metadata": {}, - "outputs": [], - "source": [ - "# Defining the stop criterion with a metric (MSE in this case)\n", - "@acl.as_stop_criterion(metric_name=MEAN_SQUARED_ERROR_MSE, threshold=0.001)\n", - "async def check_mse(*args):\n", - " return f'{code_path}/check_mse.py'" - ] - }, - { - "cell_type": "markdown", - "id": "b13550f5", - "metadata": {}, - "source": [ - "
NOTE: ROSE supports a variety of `METRICS`, which can be used to evaluate the model's performance. However, it also supports custom metrics that users can define when they specify the criterion function.
\n", - "\n", - "\n", - "\n", - "
WARNING: For custom metrics, users must specify the `operator` field as below:
\n", - "\n", - "```python\n", - "from rose.metrics import LESS_THAN_THRESHOLD\n", - "@acl.as_stop_criterion(metric_name='metric_x',\n", - " operator=LESS_THAN_THRESHOLD, threshold=0.001)\n", - "async def check_metric_x(*args):\n", - " return f'{code_path}/check_metric_x.py'\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "dcbada25-03ec-4b6c-bc52-081c9358ce0f", - "metadata": {}, - "source": [ - "### Now let us invoke the tasks to build the active learning workflow\n", - "Once we invoke the `teach` method, the ROSE builds the AL workflow and starts the learning process" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bcd3cdf2-2edc-468b-805c-286bb9291197", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Registered task 'simulation' and id of 000000 with dependencies: []\n", - "Registered task 'training' and id of 000001 with dependencies: ['simulation']\n", - "Starting Iteration-0\n", - "Registered task 'active_learn' and id of 000002 with dependencies: ['simulation', 'training']\n", - "Registered task 'check_mse' and id of 000003 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000004 with dependencies: ['active_learn']\n", - "Registered task 'training' and id of 000005 with dependencies: ['simulation']\n", - "Starting Iteration-1\n", - "Registered task 'active_learn' and id of 000006 with dependencies: ['simulation', 'training']\n", - "Registered task 'check_mse' and id of 000007 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000008 with dependencies: ['active_learn']\n", - "Registered task 'training' and id of 000009 with dependencies: ['simulation']\n", - "Starting Iteration-2\n", - "Registered task 'active_learn' and id of 000010 with dependencies: ['simulation', 'training']\n", - "Registered task 'check_mse' and id of 000011 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000012 with dependencies: ['active_learn']\n", - "Registered task 'training' and id of 000013 with dependencies: ['simulation']\n", - "Starting Iteration-3\n", - "Registered task 'active_learn' and id of 000014 with dependencies: ['simulation', 'training']\n", - "Registered task 'check_mse' and id of 000015 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000016 with dependencies: ['active_learn']\n", - "Registered task 'training' and id of 000017 with dependencies: ['simulation']\n", - "Starting Iteration-4\n", - "Registered task 'active_learn' and id of 000018 with dependencies: ['simulation', 'training']\n", - "Registered task 'check_mse' and id of 000019 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000020 with dependencies: ['active_learn']\n", - "Registered task 'training' and id of 000021 with dependencies: ['simulation']\n", - "Starting Iteration-5\n", - "Registered task 'active_learn' and id of 000022 with dependencies: ['simulation', 'training']\n", - "Registered task 'check_mse' and id of 000023 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000024 with dependencies: ['active_learn']\n", - "Registered task 'training' and id of 000025 with dependencies: ['simulation']\n", - "Starting Iteration-6\n", - "Registered task 'active_learn' and id of 000026 with dependencies: ['simulation', 'training']\n", - "Registered task 'check_mse' and id of 000027 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000028 with dependencies: ['active_learn']\n", - "Registered task 'training' and id of 000029 with dependencies: ['simulation']\n", - "Starting Iteration-7\n", - "Registered task 'active_learn' and id of 000030 with dependencies: ['simulation', 'training']\n", - "Registered task 'check_mse' and id of 000031 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000032 with dependencies: ['active_learn']\n", - "Registered task 'training' and id of 000033 with dependencies: ['simulation']\n", - "Starting Iteration-8\n", - "Registered task 'active_learn' and id of 000034 with dependencies: ['simulation', 'training']\n", - "Registered task 'check_mse' and id of 000035 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000036 with dependencies: ['active_learn']\n", - "Registered task 'training' and id of 000037 with dependencies: ['simulation']\n", - "Starting Iteration-9\n", - "Registered task 'active_learn' and id of 000038 with dependencies: ['simulation', 'training']\n", - "Registered task 'check_mse' and id of 000039 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000040 with dependencies: ['active_learn']\n", - "Registered task 'training' and id of 000041 with dependencies: ['simulation']\n" - ] - } - ], - "source": [ - "# Start the learning process\n", - "await acl.start(max_iter=10)" - ] - }, - { - "cell_type": "markdown", - "id": "af11cd52-0c96-4c0a-8fa8-b77e0dc805bf", - "metadata": {}, - "source": [ - "### Once the learning process is finished, we will make sure to terminate the resources" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e0274192-3c6c-4045-b867-d678e675415b", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Shutdown is triggered, terminating the resources gracefully\n" - ] - } - ], - "source": [ - "# Start the learning process\n", - "await acl.shutdown()" - ] - }, - { - "cell_type": "markdown", - "id": "658e7c4f-9c74-4926-bc94-49e25e2bcc23", - "metadata": {}, - "source": [ - "### To better understand our model performance, we will plot the MSE of each iteration." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f9d2a44f-787b-42ab-8716-1b6244fb4305", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "Text(0.5, 1.0, 'MSE Values for Machine Learning Model')" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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7du3T61bt9batWvTPdfgwYO1Y8eObH39PXv21FdffZX673nq1CktXbo0w/AtZf37vWzZMrVs2VI7duzQkCFDNG7cOLVp00aLFi1yOi45OVmRkZEqXry43n33XbVq1Urjxo3TpEmTslR/t27ddP78eY0ePVrdunXTlClT9Nprrzkd89Zbb+nhhx9WtWrV9N577+mZZ55RTEyMWrZsecOfxaw4fvy4mjRpoh9++EFPPvmk3n//fVWtWlWDBg1K/beUpPj4eP33v/9V69atNWbMGI0aNUonT55UZGSkNm/enO55J0+erA8//FCPPvqoxo0b5xSGs/J1Z+app57Sli1bNHLkSD3++OP69ttv0w13HT58uF577TU1bNhQ77zzjqpVq6bIyEglJCRk63vTq1cvffvtt7pw4YIkcwVvzpw51/356tatmwIDAzV69Gg98sgjio6OVvPmzZ3+rZYuXaquXbvKZrNp9OjRioqK0oABAzKcm/fYY4/phRde0J133qn3339fAwYM0IwZMxQZGakrV65k6+sB4AHsAOBlJk+ebJdkX7dunX3ChAn2QoUK2S9evGi32+32hx56yN6mTRu73W63V6xY0d65c+fUx128eNFevXp1uyR7xYoV7f3797d//vnn9uPHj6d7jZEjR9olZfhRvXr1G9b40Ucf2SXZlyxZkrovOTnZXq5cOXvTpk2davq7xx57zJ4/f3775cuXU/f169fPXrFixdT7y5cvt0uyL1++3Omx+/bts0uyT548OXVf27Zt7XXq1HF6vpSUFHuzZs3s1apVS91Xt25dp+9XVjle85133nHaHxUVZQ8ODrb/+eefqfuOHj1qL1SokL1ly5ap+xz/ns2bN7dfvXo1W6+3bds2uyT76tWr7Xa7+b4XLFjQnpCQYO/Xr5+9QIECTo/Nyvf76tWr9sqVK9srVqxoP3v2rNOxKSkpqdv9+vWzS7K//vrrTsfUq1fP3qBBA6d9kuwjR45Mve/4+Ro4cKDTcQ888IC9ePHiqff3799vDwwMtL/11ltOx23dutUeFBSUbv/fpf1dycygQYPsZcqUsZ86dcppf48ePeyhoaGp37OrV6/aExMTnY45e/asPSwszOnrcPz7FC5c2H7ixAmn47P6ddvt5ve3X79+6b6Wdu3aOf07PPvss/bAwED7uXPn7Ha73R4bG2sPCgqyR0VFOT3fqFGj7JKcnjMzkuxPPPGE/cyZM/bg4GD7tGnT7Ha73f6///3PbrPZ7Pv370/9Wk6ePGm32+32pKQke6lSpey1a9e2X7p0KfW5Fi1aZJdkHzFiROq+iIgIe5kyZVJrttvt9qVLl6b+bXJYvXq1XZJ9xowZTvUtXrw43f5WrVrZW7VqdcOvDYC1uPIFwKt169ZNly5d0qJFi3T+/HktWrQo07PS+fLl09q1a/XCCy9IMmepBw0apDJlyuipp55SYmJiusfMmzdPy5Ytc/qYPHnyDevq3r278uTJ4zRkaeXKlTpy5EjqkENHTQ7nz5/XqVOn1KJFC128eFG7du3K8vchM2fOnNGPP/6YeqXBcfXu9OnTioyM1J49e1KHRBUpUkTbt2/Xnj17bvp1k5OTtXTpUkVFRemWW25J3V+mTBn16tVLP/30k+Lj450e88gjjygwMDBbr1OrVi3dfvvtqVc/Z86cqfvvv1/58+fP8PisfL83bdqkffv26Zlnnkk3j81ms6V7zn/+859O91u0aKG//vorS/Vn9NjTp0+nfm+io6OVkpKibt26OV19LV26tKpVq5ZuyF922e12zZs3T/fee6/sdrvTa0RGRiouLi71ymdgYGDqnK2UlBSdOXNGV69eVcOGDTO8Otq1a1eVLFkyR1/39Tz66KNO/w4tWrRQcnKyDhw4IEmKiYnR1atXNXjwYKfHPfXUUzd87r8rWrSoOnbs6PTz1axZs9Sr6GmtX79eJ06c0ODBg53m+HXu3Fk1atTQ//73P0nSsWPHtHnzZvXr10+hoaGpx7Vv3141a9Z0es45c+YoNDRU7du3d/q3adCggQoWLHjT//4A3I+GGwC8WsmSJdWuXTvNnDlTFy9eVHJysh588MFMjw8NDdXYsWM1duxYHThwQDExMXr33Xc1YcIEhYaGputQ2LJly2w33JCk4sWLKzIyUvPnz9fEiRNTOzMGBQWpW7duqcdt375dr7zyin788cd0bzzj4uKy/bp/t3fvXtntdr366qt69dVXMzzmxIkTKleunF5//XXdf//9uvXWW1W7dm117NhRffv21e23357t1z158qQuXryo6tWrp/vcbbfdppSUFB06dEi1atVK3V+5cuVsv45khoaNGzdOzz77rH755Re99NJLmR6ble+3Y25gZnN60goJCUkXMIoWLZrl+TgVKlRI91hJOnv2rAoXLqw9e/bIbrerWrVqGT4+T548WXqdzJw8eVLnzp3TpEmTMh0qeeLEidTtqVOnaty4cdq1a5fTkLeM/u2u9+95o6/7eq73WEmpIaxq1apOxxUrViz12Ozo1auX+vbtq4MHD2rBggUaO3Zshsc5Xjejn/kaNWqkLovhOC6jf9Pq1as7Bdk9e/YoLi5OpUqVyvA10/7bAPAOhC8AXq9Xr1565JFHFBsbq7vvvjvLXfcqVqyogQMH6oEHHtAtt9yiGTNmZNoePif69OmjRYsWadGiRbrvvvs0b948dejQIfXN+rlz59SqVSsVLlxYr7/+uqpUqaKQkBBt3LhR//rXv5SSkpLpc2d0BUZSujlmjud4/vnnFRkZmeFjHG9SW7ZsqT///FPffPONli5dqv/+97/6z3/+o4kTJ2a63pErpb0qlR09e/bU8OHD9cgjj6h48eLq0KFDhsfdzPc7M9m9UpfVx9v/fw5bSkqKbDabvv/++wyPvdl1zBxfc58+fdSvX78Mj3GE7+nTp6t///6KiorSCy+8oFKlSqXObXIE1rSu9+95o6/7em7msTlx3333KW/evOrXr58SExOdTp7ktpSUFJUqVUozZszI8POZXVkE4LkIXwC83gMPPKDHHntMv/76q2bPnp3txxctWlRVqlTRtm3bXFrXfffdp0KFCmnmzJnKkyePzp496zTkcMWKFTp9+rSio6PVsmXL1P379u3LUs2S0jVccJxVd3AM+cuTJ4/atWt3w+ctVqyYBgwYoAEDBujChQtq2bKlRo0ale3wVbJkSeXPn1+7d+9O97ldu3YpICBA4eHh2XrOzFSoUEF33nmnVqxYoccffzzT9bSy+v2uUqWKJGnbtm1Z+p7lpipVqshut6ty5cq69dZbXf78JUuWVKFChZScnHzDr3Xu3Lm65ZZbFB0d7RT+R44c6fK6boZjSODevXudrr6dPn06Rx0C8+XLp6ioKE2fPl133313plfCHa+7e/du3XXXXU6f2717d+rnHbcZDe/9++9LlSpV9MMPP+jOO+/M8ckJAJ6FOV8AvF7BggX1ySefaNSoUbr33nszPW7Lli3p1v6STGDZsWNHhsOFbka+fPn0wAMP6LvvvtMnn3yiAgUKOK0X5DiDn/aMfVJSkj7++OMbPnfFihUVGBioVatWOe3/+2NLlSql1q1b69NPP9WxY8fSPU/aFt2nT592+lzBggVVtWrVDOfC3UhgYKA6dOigb775Rvv370/df/z48dSFsV3V2l+S3nzzTY0cOfK683qy+v2uX7++KleurPHjx6cLt7l1dSUzXbp0UWBgoF577bV0r22329P9m2VXYGBg6npVGZ18SPvzkdH3b+3atVqzZs1N1eBqbdu2VVBQkD755BOn/RMmTMjxcz7//PMaOXJkpkN3Jalhw4YqVaqUJk6c6PQ78/3332vnzp3q3LmzJDPvMSIiQlOnTnUaWrxs2TLt2LHD6Tm7deum5ORkvfHGG+le7+rVqy7pdgnAvbjyBcAnZDZkKq1ly5Zp5MiRuu+++9SkSRMVLFhQf/31l7744gslJiY6rcPkMHfu3AyHdrVv315hYWE3fM0+ffroyy+/1JIlS9S7d2+nhYObNWumokWLql+/fnr66adls9k0bdq0LL3BDw0N1UMPPaQPP/xQNptNVapU0aJFizKcA/LRRx+pefPmqlOnjh555BHdcsstOn78uNasWaPDhw9ry5YtkqSaNWuqdevWatCggYoVK6b169dr7ty56dp4Z9Wbb76pZcuWqXnz5ho8eLCCgoL06aefKjExMdN5MznVqlUrtWrV6rrHZPX7HRAQoE8++UT33nuvIiIiNGDAAJUpU0a7du3S9u3btWTJEpfWfj1VqlTRm2++qeHDh2v//v2KiopSoUKFtG/fPs2fP1+PPvqonn/++Rs+zxdffKHFixen2z9kyBC9/fbbWr58uRo3bqxHHnlENWvW1JkzZ7Rx40b98MMPOnPmjCTpnnvuUXR0tB544AF17txZ+/bt08SJE1WzZs3UVuyeICwsLHV5gPvuu08dO3bUli1b9P3336tEiRKZDtm9nrp16zot5J2RPHnyaMyYMRowYIBatWqlnj176vjx43r//fdVqVIlPfvss6nHjh49Wp07d1bz5s01cOBAnTlzRh9++KFq1arl9L1s1aqVHnvsMY0ePVqbN29Whw4dlCdPHu3Zs0dz5szR+++/f905rgA8D+ELgN/o2rWrzp8/r6VLl+rHH3/UmTNnVLRoUd1xxx167rnn1KZNm3SPefzxxzN8ruXLl2cpfN11110qU6aMjh075jTkUDJNORYtWqTnnntOr7zyiooWLao+ffqobdu2mc7PSuvDDz/UlStXNHHiROXNm1fdunXT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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "import matplotlib.pyplot as plt\n", - "# Convert string values to floats\n", - "mse_values = acl.get_metric_results()\n", - "mse_values = [float(mse.strip()) for mse in mse_values]\n", - "\n", - "# Plot the MSE values\n", - "plt.figure(figsize=(10, 6))\n", - "plt.plot(mse_values, marker='o', color='b', linestyle='-', markersize=6)\n", - "\n", - "# Add labels and title\n", - "plt.xlabel('Index')\n", - "plt.ylabel('MSE Value')\n", - "plt.title('MSE Values for Machine Learning Model')" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/examples/active_learn/basic/check_mse.py b/examples/active_learn/basic/check_mse.py index c509ed4e..3ddf1884 100644 --- a/examples/active_learn/basic/check_mse.py +++ b/examples/active_learn/basic/check_mse.py @@ -1,14 +1,13 @@ # check.py -import sys import pickle -import numpy as np +import numpy as np from sklearn.metrics import mean_squared_error -def check(input_file='acl_output.pkl'): +def check(input_file="acl_output.pkl"): # Load the model after active learning - with open(input_file, 'rb') as f: + with open(input_file, "rb") as f: model = pickle.load(f) # Simulate evaluation (in practice, you would use a validation dataset) @@ -21,5 +20,6 @@ def check(input_file='acl_output.pkl'): mse_eval = mean_squared_error(y_eval, y_pred_eval) print(mse_eval) + if __name__ == "__main__": check() # Running the check task diff --git a/examples/active_learn/basic/run_me.py b/examples/active_learn/basic/run_me.py index a4d4acb9..62936a54 100644 --- a/examples/active_learn/basic/run_me.py +++ b/examples/active_learn/basic/run_me.py @@ -2,39 +2,39 @@ import os import sys -from radical.asyncflow import RadicalExecutionBackend, WorkflowEngine +from radical.asyncflow import WorkflowEngine +from rhapsody.backends import RadicalExecutionBackend from rose.al.active_learner import SequentialActiveLearner from rose.metrics import MEAN_SQUARED_ERROR_MSE async def rose_al(): - - engine = await RadicalExecutionBackend({'resource': 'local.localhost'}) + engine = await RadicalExecutionBackend({"resource": "local.localhost"}) asyncflow = await WorkflowEngine.create(engine) acl = SequentialActiveLearner(asyncflow) - code_path = f'{sys.executable} {os.getcwd()}' + code_path = f"{sys.executable} {os.getcwd()}" # Define and register the simulation task @acl.simulation_task - async def simulation(*args): - return f'{code_path}/sim.py' + async def simulation(*args, task_description={"shell": True}): + return f"{code_path}/sim.py" # Define and register the training task @acl.training_task - async def training(*args): - return f'{code_path}/train.py' + async def training(*args, task_description={"shell": True}): + return f"{code_path}/train.py" # Define and register the active learning task @acl.active_learn_task - async def active_learn(*args): - return f'{code_path}/active.py' + async def active_learn(*args, task_description={"shell": True}): + return f"{code_path}/active.py" # Defining the stop criterion with a metric (MSE in this case) @acl.as_stop_criterion(metric_name=MEAN_SQUARED_ERROR_MSE, threshold=0.1) - async def check_mse(*args): - return f'{code_path}/check_mse.py' + async def check_mse(*args, task_description={"shell": True}): + return f"{code_path}/check_mse.py" # Start the active learning process async for state in acl.start(): diff --git a/examples/active_learn/basic/sim.py b/examples/active_learn/basic/sim.py index fde935b0..6f4639f3 100644 --- a/examples/active_learn/basic/sim.py +++ b/examples/active_learn/basic/sim.py @@ -1,8 +1,10 @@ # sim.py -import numpy as np import pickle -def sim(output_file='sim_output.pkl'): +import numpy as np + + +def sim(output_file="sim_output.pkl"): # Generate initial labeled data for simulation X = np.random.rand(100, 1) # 100 samples, 1 feature y = 2 * X + 1 + np.random.normal(0, 0.1, (100, 1)) # Linear relationship with noise @@ -14,7 +16,7 @@ def sim(output_file='sim_output.pkl'): X_unlabeled = np.random.rand(100, 1) # 100 additional unlabeled samples unlabeled_data = X_unlabeled - with open(output_file, 'wb') as f: + with open(output_file, "wb") as f: pickle.dump((labeled_data, unlabeled_data), f) print(f"Simulation completed. Data saved to {output_file}") diff --git a/examples/active_learn/basic/train.py b/examples/active_learn/basic/train.py index 983911a0..ad991991 100644 --- a/examples/active_learn/basic/train.py +++ b/examples/active_learn/basic/train.py @@ -1,29 +1,32 @@ # train.py import pickle + from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error -def train(input_file='sim_output.pkl', output_file='train_output.pkl'): + +def train(input_file="sim_output.pkl", output_file="train_output.pkl"): # Load labeled data - with open(input_file, 'rb') as f: + with open(input_file, "rb") as f: (X_labeled, y_labeled), _ = pickle.load(f) - + # Train a simple linear regression model model = LinearRegression() model.fit(X_labeled, y_labeled) - + # Predict and compute Mean Squared Error (MSE) as a simple performance metric y_pred = model.predict(X_labeled) mse = mean_squared_error(y_labeled, y_pred) - + print(f"Training completed. MSE: {mse:.4f}") # Save the trained model - with open(output_file, 'wb') as f: + with open(output_file, "wb") as f: pickle.dump(model, f) - + print(f"Model saved to {output_file}") return output_file, mse + if __name__ == "__main__": train() # Running the training task diff --git a/examples/active_learn/parallel/active.py b/examples/active_learn/parallel/active.py index a4f75afd..46860297 100644 --- a/examples/active_learn/parallel/active.py +++ b/examples/active_learn/parallel/active.py @@ -1,22 +1,26 @@ # active.py import pickle + import numpy as np -from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error -def acl(input_file='train_output.pkl', output_file='acl_output.pkl'): + +def acl(input_file="train_output.pkl", output_file="acl_output.pkl"): # Load model and data - with open(input_file, 'rb') as f: + with open(input_file, "rb") as f: model = pickle.load(f) - - with open('sim_output.pkl', 'rb') as f: + + with open("sim_output.pkl", "rb") as f: (X_labeled, y_labeled), X_unlabeled = pickle.load(f) - print(f"Original shapes - X_labeled: {X_labeled.shape}, y_labeled: {y_labeled.shape}, X_unlabeled: {X_unlabeled.shape}") + print( + f"Original shapes - X_labeled: {X_labeled.shape}, " + f"y_labeled: {y_labeled.shape}, X_unlabeled: {X_unlabeled.shape}" + ) # Predict on the unlabeled data to find the most uncertain samples y_pred_unlabeled = model.predict(X_unlabeled) - + # Calculate uncertainty - handle multi-dimensional y properly if y_labeled.ndim > 1 and y_labeled.shape[1] > 1: # For multi-output case, use mean across features @@ -30,14 +34,16 @@ def acl(input_file='train_output.pkl', output_file='acl_output.pkl'): n_select = min(10, len(X_unlabeled)) uncertain_indices = uncertainty.argsort()[-n_select:] X_selected = X_unlabeled[uncertain_indices] - + # Generate labels for selected data - match the original y structure if y_labeled.shape[1] == X_labeled.shape[1]: # Multi-output regression case y_selected = 2 * X_selected + 1 + np.random.normal(0, 0.1, X_selected.shape) else: # Single output case - create appropriate shape - y_selected = np.mean(2 * X_selected + 1, axis=1, keepdims=True) + np.random.normal(0, 0.1, (X_selected.shape[0], 1)) + y_selected = np.mean(2 * X_selected + 1, axis=1, keepdims=True) + np.random.normal( + 0, 0.1, (X_selected.shape[0], 1) + ) print(f"Selected shapes - X_selected: {X_selected.shape}, y_selected: {y_selected.shape}") @@ -50,8 +56,14 @@ def acl(input_file='train_output.pkl', output_file='acl_output.pkl'): # Convert y_labeled to multi-output (shouldn't happen in your case) y_selected = np.repeat(y_selected, y_labeled.shape[1], axis=1) - print(f"Final shapes before stacking - X_labeled: {X_labeled.shape}, X_selected: {X_selected.shape}") - print(f"Final shapes before stacking - y_labeled: {y_labeled.shape}, y_selected: {y_selected.shape}") + print( + f"Final shapes before stacking - X_labeled: {X_labeled.shape}, " + f"X_selected: {X_selected.shape}" + ) + print( + f"Final shapes before stacking - y_labeled: {y_labeled.shape}, " + f"y_selected: {y_selected.shape}" + ) # Add selected uncertain data to labeled set X_labeled = np.vstack([X_labeled, X_selected]) @@ -63,14 +75,15 @@ def acl(input_file='train_output.pkl', output_file='acl_output.pkl'): # Evaluate retrained model y_pred = model.predict(X_labeled) mse = mean_squared_error(y_labeled, y_pred) - + print(f"Active Learning completed. MSE: {mse:.4f}") # Save the updated model and the new labeled data - with open(output_file, 'wb') as f: + with open(output_file, "wb") as f: pickle.dump(model, f) return output_file, mse + if __name__ == "__main__": acl() # Running the active learning task diff --git a/examples/active_learn/parallel/check_mse.py b/examples/active_learn/parallel/check_mse.py index 67765fc6..878eae35 100644 --- a/examples/active_learn/parallel/check_mse.py +++ b/examples/active_learn/parallel/check_mse.py @@ -1,25 +1,26 @@ # check_mse.py -import sys import pickle + import numpy as np from sklearn.metrics import mean_squared_error -def check(input_file='acl_output.pkl'): + +def check(input_file="acl_output.pkl"): # Load the model after active learning - with open(input_file, 'rb') as f: + with open(input_file, "rb") as f: model = pickle.load(f) # Load the original simulation data to get the correct feature dimensions - with open('sim_output.pkl', 'rb') as f: + with open("sim_output.pkl", "rb") as f: (X_labeled_orig, y_labeled_orig), _ = pickle.load(f) - + # Get the number of features from the original training data n_features = X_labeled_orig.shape[1] n_output_features = y_labeled_orig.shape[1] if y_labeled_orig.ndim > 1 else 1 # Create evaluation data with the correct number of features X_eval = np.random.rand(100, n_features) # Match the feature dimension - + # Generate evaluation labels with the same structure as training data if n_output_features == n_features: # Multi-output case: each output corresponds to input features @@ -28,10 +29,9 @@ def check(input_file='acl_output.pkl'): # Single output case: aggregate the features somehow y_eval = np.mean(2 * X_eval + 1, axis=1, keepdims=True) + np.random.normal(0, 0.1, (100, 1)) - # Evaluate the model on the new data y_pred_eval = model.predict(X_eval) - + # Ensure shapes match for MSE calculation if y_eval.shape != y_pred_eval.shape: # Handle potential shape mismatches @@ -44,6 +44,6 @@ def check(input_file='acl_output.pkl'): # Return the MSE for the framework to use print(mse_eval) # Print the final result + if __name__ == "__main__": mse = check() # Running the check task - diff --git a/examples/active_learn/parallel/run_me_per_learner_config.py b/examples/active_learn/parallel/run_me_per_learner_config.py index d3cc3595..a91ceea0 100644 --- a/examples/active_learn/parallel/run_me_per_learner_config.py +++ b/examples/active_learn/parallel/run_me_per_learner_config.py @@ -3,7 +3,8 @@ import sys from concurrent.futures import ThreadPoolExecutor -from radical.asyncflow import ConcurrentExecutionBackend, WorkflowEngine +from radical.asyncflow import WorkflowEngine +from rhapsody.backends import ConcurrentExecutionBackend from rose import LearnerConfig, TaskConfig from rose.al import ParallelActiveLearner @@ -15,50 +16,53 @@ async def run_al_parallel(): asyncflow = await WorkflowEngine.create(engine) al = ParallelActiveLearner(asyncflow) - code_path = f'{sys.executable} {os.getcwd()}' + code_path = f"{sys.executable} {os.getcwd()}" # Define and register the simulation task @al.simulation_task - async def simulation(*args, **kwargs): + async def simulation(*args, task_description={"shell": True}, **kwargs): n_labeled = kwargs.get("--n_labeled", 100) n_features = kwargs.get("--n_features", 2) return f"{code_path}/sim.py --n_labeled {n_labeled} --n_features {n_features}" # Define and register the training task @al.training_task - async def training(*args, **kwargs): - return f'{code_path}/train.py' + async def training(*args, task_description={"shell": True}, **kwargs): + return f"{code_path}/train.py" # Define and register the active learning task @al.active_learn_task - async def active_learn(*args, **kwargs): - return f'{code_path}/active.py' + async def active_learn(*args, task_description={"shell": True}, **kwargs): + return f"{code_path}/active.py" # Defining the stop criterion with a metric (MSE in this case) - @al.as_stop_criterion(metric_name=MEAN_SQUARED_ERROR_MSE, threshold=0.1) - async def check_mse(*args, **kwargs): - return f'{code_path}/check_mse.py' + @al.as_stop_criterion(metric_name=MEAN_SQUARED_ERROR_MSE, threshold=0.01) + async def check_mse(*args, task_description={"shell": True}, **kwargs): + return f"{code_path}/check_mse.py" - adaptive_sim = al.create_adaptive_schedule('simulation', + adaptive_sim = al.create_adaptive_schedule( + "simulation", lambda i: { - 'kwargs': { - '--n_labeled': str(100 + i * 50), # Increase labeled data each iteration - '--n_features': 2 + "kwargs": { + "--n_labeled": str(100 + i * 50), # Increase labeled data each iteration + "--n_features": 2, } - }) + }, + ) # Start the parallel active learning process - results = await al.start( + async for state in al.start( + max_iter=5, parallel_learners=2, learner_configs=[ LearnerConfig(simulation=adaptive_sim), - LearnerConfig(simulation=TaskConfig(kwargs={"--n_labeled": "300", - "--n_features": 4})) - ] - ) - print(f"Parallel learning completed. Results: {results}") + LearnerConfig(simulation=TaskConfig(kwargs={"--n_labeled": "300", "--n_features": 4})), + ], + ): + print(f"Learner {state.learner_id}, iteration {state.iteration}: {state.metric_value}") await al.shutdown() + if __name__ == "__main__": asyncio.run(run_al_parallel()) diff --git a/examples/active_learn/parallel/run_me_per_learner_per_iter_config.py b/examples/active_learn/parallel/run_me_per_learner_per_iter_config.py index b80a83fe..a99e6b50 100644 --- a/examples/active_learn/parallel/run_me_per_learner_per_iter_config.py +++ b/examples/active_learn/parallel/run_me_per_learner_per_iter_config.py @@ -2,7 +2,8 @@ import os import sys -from radical.asyncflow import RadicalExecutionBackend, WorkflowEngine +from radical.asyncflow import WorkflowEngine +from rhapsody.backends import ConcurrentExecutionBackend from rose import LearnerConfig, TaskConfig from rose.al import ParallelActiveLearner @@ -10,15 +11,15 @@ async def run_al_parallel(): - engine = await RadicalExecutionBackend({'resource': 'local.localhost'}) + engine = await ConcurrentExecutionBackend() asyncflow = await WorkflowEngine.create(engine) al = ParallelActiveLearner(asyncflow) - code_path = f'{sys.executable} {os.getcwd()}' + code_path = f"{sys.executable} {os.getcwd()}" # Define and register the simulation task @al.simulation_task - async def simulation(*args, **kwargs): + async def simulation(*args, task_description={"shell": True}, **kwargs): n_labeled = kwargs.get("--n_labeled", 100) n_features = kwargs.get("--n_features", 2) @@ -26,27 +27,25 @@ async def simulation(*args, **kwargs): # Define and register the training task @al.training_task - async def training(*args, **kwargs): - return f'{code_path}/train.py' + async def training(*args, task_description={"shell": True}, **kwargs): + return f"{code_path}/train.py" # Define and register the active learning task @al.active_learn_task - async def active_learn(*args, **kwargs): - return f'{code_path}/active.py' + async def active_learn(*args, task_description={"shell": True}, **kwargs): + return f"{code_path}/active.py" # Defining the stop criterion with a metric (MSE in this case) @al.as_stop_criterion(metric_name=MEAN_SQUARED_ERROR_MSE, threshold=0.1) - async def check_mse(*args, **kwargs): - return f'{code_path}/check_mse.py' + async def check_mse(*args, task_description={"shell": True}, **kwargs): + return f"{code_path}/check_mse.py" # Start the parallel active learning process with custom configs - results = await al.start( + async for state in al.start( parallel_learners=3, learner_configs=[ # Learner 0: Same config for all iterations (your current pattern) - LearnerConfig(simulation=TaskConfig(kwargs={"--n_labeled": "200", - "--n_features": 2})), - + LearnerConfig(simulation=TaskConfig(kwargs={"--n_labeled": "200", "--n_features": 2})), # Learner 1: Different configs per iteration LearnerConfig( simulation={ @@ -57,9 +56,9 @@ async def check_mse(*args, **kwargs): } ), None, - ] - ) - print(f"Parallel learning completed. Results: {results}") + ], + ): + print(f"Learner {state.learner_id}, iteration {state.iteration}: {state.metric_value}") await engine.shutdown() diff --git a/examples/active_learn/parallel/run_me_with_dynamic_config.py b/examples/active_learn/parallel/run_me_with_dynamic_config.py index b5a72dce..3392d87d 100644 --- a/examples/active_learn/parallel/run_me_with_dynamic_config.py +++ b/examples/active_learn/parallel/run_me_with_dynamic_config.py @@ -2,7 +2,8 @@ import os import sys -from radical.asyncflow import RadicalExecutionBackend, WorkflowEngine +from radical.asyncflow import WorkflowEngine +from rhapsody.backends import RadicalExecutionBackend from rose import LearnerConfig, TaskConfig from rose.al import ParallelActiveLearner @@ -10,11 +11,11 @@ async def run_al_parallel(): - engine = await RadicalExecutionBackend({'resource': 'local.localhost'}) + engine = await RadicalExecutionBackend({"resource": "local.localhost"}) asyncflow = await WorkflowEngine.create(engine) al = ParallelActiveLearner(asyncflow) - code_path = f'{sys.executable} {os.getcwd()}' + code_path = f"{sys.executable} {os.getcwd()}" # Define and register the simulation task @al.simulation_task @@ -27,42 +28,42 @@ async def simulation(*args, **kwargs): # Define and register the training task @al.training_task async def training(*args, **kwargs): - return f'{code_path}/train.py' + return f"{code_path}/train.py" # Define and register the active learning task @al.active_learn_task async def active_learn(*args, **kwargs): - return f'{code_path}/active.py' - + return f"{code_path}/active.py" # Defining the stop criterion with a metric (MSE in this case) @al.as_stop_criterion(metric_name=MEAN_SQUARED_ERROR_MSE, threshold=0.1) async def check_mse(*args, **kwargs): - return f'{code_path}/check_mse.py' - + return f"{code_path}/check_mse.py" # Create adaptive simulation config - adaptive_sim = al.create_adaptive_schedule('simulation', + adaptive_sim = al.create_adaptive_schedule( + "simulation", lambda i: { - 'kwargs': { - '--n_labeled': str(100 + i * 50), # Increase labeled data each iteration - '--n_features': 2 + "kwargs": { + "--n_labeled": str(100 + i * 50), # Increase labeled data each iteration + "--n_features": 2, } - }) + }, + ) # Start the parallel active learning process - results = await al.start( + async for state in al.start( max_iter=1, parallel_learners=2, learner_configs=[ LearnerConfig(simulation=adaptive_sim), - LearnerConfig(simulation=TaskConfig(kwargs={"--n_labeled": "300", - "--n_features": 2})) - ] - ) - print(f"Parallel learning completed. Results: {results}") + LearnerConfig(simulation=TaskConfig(kwargs={"--n_labeled": "300", "--n_features": 2})), + ], + ): + print(f"Learner {state.learner_id}, iteration {state.iteration}: {state.metric_value}") await al.shutdown() + if __name__ == "__main__": asyncio.run(run_al_parallel()) diff --git a/examples/active_learn/parallel/sim.py b/examples/active_learn/parallel/sim.py index bf44c809..bfaee32d 100644 --- a/examples/active_learn/parallel/sim.py +++ b/examples/active_learn/parallel/sim.py @@ -1,11 +1,15 @@ -import numpy as np -import pickle import argparse +import pickle + +import numpy as np + -def sim(n_labeled=100, n_unlabeled=100, n_features=1, output_file='sim_output.pkl'): +def sim(n_labeled=100, n_unlabeled=100, n_features=1, output_file="sim_output.pkl"): # Generate initial labeled data X = np.random.rand(n_labeled, n_features) - y = 2 * X + 1 + np.random.normal(0, 0.1, (n_labeled, n_features)) # Linear relationship with noise + y = ( + 2 * X + 1 + np.random.normal(0, 0.1, (n_labeled, n_features)) + ) # Linear relationship with noise labeled_data = (X, y) # Generate unlabeled data @@ -13,7 +17,7 @@ def sim(n_labeled=100, n_unlabeled=100, n_features=1, output_file='sim_output.pk unlabeled_data = X_unlabeled # Save both to file - with open(output_file, 'wb') as f: + with open(output_file, "wb") as f: pickle.dump((labeled_data, unlabeled_data), f) print(f"Simulation completed. Data saved to {output_file}") @@ -21,11 +25,30 @@ def sim(n_labeled=100, n_unlabeled=100, n_features=1, output_file='sim_output.pk if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Simulate labeled and unlabeled data for active learning.") - parser.add_argument("--n_labeled", type=int, default=100, help="Number of labeled samples (default: 100)") - parser.add_argument("--n_unlabeled", type=int, default=100, help="Number of unlabeled samples (default: 100)") - parser.add_argument("--n_features", type=int, default=1, help="Number of features per sample (default: 1)") - parser.add_argument("--output_file", type=str, default="sim_output.pkl", help="Output file name") + parser = argparse.ArgumentParser( + description="Simulate labeled and unlabeled data for active learning." + ) + parser.add_argument( + "--n_labeled", + type=int, + default=100, + help="Number of labeled samples (default: 100)", + ) + parser.add_argument( + "--n_unlabeled", + type=int, + default=100, + help="Number of unlabeled samples (default: 100)", + ) + parser.add_argument( + "--n_features", + type=int, + default=1, + help="Number of features per sample (default: 1)", + ) + parser.add_argument( + "--output_file", type=str, default="sim_output.pkl", help="Output file name" + ) args = parser.parse_args() @@ -33,6 +56,5 @@ def sim(n_labeled=100, n_unlabeled=100, n_features=1, output_file='sim_output.pk n_labeled=args.n_labeled, n_unlabeled=args.n_unlabeled, n_features=args.n_features, - output_file=args.output_file + output_file=args.output_file, ) - diff --git a/examples/active_learn/parallel/train.py b/examples/active_learn/parallel/train.py index 983911a0..ad991991 100644 --- a/examples/active_learn/parallel/train.py +++ b/examples/active_learn/parallel/train.py @@ -1,29 +1,32 @@ # train.py import pickle + from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error -def train(input_file='sim_output.pkl', output_file='train_output.pkl'): + +def train(input_file="sim_output.pkl", output_file="train_output.pkl"): # Load labeled data - with open(input_file, 'rb') as f: + with open(input_file, "rb") as f: (X_labeled, y_labeled), _ = pickle.load(f) - + # Train a simple linear regression model model = LinearRegression() model.fit(X_labeled, y_labeled) - + # Predict and compute Mean Squared Error (MSE) as a simple performance metric y_pred = model.predict(X_labeled) mse = mean_squared_error(y_labeled, y_pred) - + print(f"Training completed. MSE: {mse:.4f}") # Save the trained model - with open(output_file, 'wb') as f: + with open(output_file, "wb") as f: pickle.dump(model, f) - + print(f"Model saved to {output_file}") return output_file, mse + if __name__ == "__main__": train() # Running the training task diff --git a/examples/active_learn/state_control.py b/examples/active_learn/state_control.py index c1cec234..ed07b912 100644 --- a/examples/active_learn/state_control.py +++ b/examples/active_learn/state_control.py @@ -11,25 +11,24 @@ """ import asyncio +import logging +import pickle from concurrent.futures import ProcessPoolExecutor from pathlib import Path -import pickle -import typeguard import numpy as np +import typeguard +from radical.asyncflow import WorkflowEngine +from radical.asyncflow.logging import init_default_logger +from rhapsody.backends import ConcurrentExecutionBackend from sklearn.gaussian_process import GaussianProcessRegressor from sklearn.gaussian_process.kernels import RBF, WhiteKernel from sklearn.metrics import mean_squared_error -from radical.asyncflow import ConcurrentExecutionBackend, WorkflowEngine - from rose.al import SequentialActiveLearner from rose.learner import LearnerConfig, TaskConfig from rose.metrics import MEAN_SQUARED_ERROR_MSE -import logging -from radical.asyncflow.logging import init_default_logger - logger = logging.getLogger(__name__) # ============================================================================= @@ -46,13 +45,16 @@ def target_function(X: np.ndarray) -> np.ndarray: def save_data(X_labeled, y_labeled, X_pool, y_pool, model=None): """Save data to file for cross-process access.""" with open(DATA_FILE, "wb") as f: - pickle.dump({ - "X_labeled": X_labeled, - "y_labeled": y_labeled, - "X_pool": X_pool, - "y_pool": y_pool, - "model": model, - }, f) + pickle.dump( + { + "X_labeled": X_labeled, + "y_labeled": y_labeled, + "X_pool": X_pool, + "y_pool": y_pool, + "model": model, + }, + f, + ) def load_data(): @@ -92,6 +94,7 @@ async def simulation(*args, n_initial: int = 10, n_pool: int = 100) -> dict: "unlabeled_count": n_pool, } + @typeguard.typechecked async def training(*args, length_scale: float = 1.0) -> dict: """Train a Gaussian Process model. @@ -108,13 +111,15 @@ async def training(*args, length_scale: float = 1.0) -> dict: # Save model save_data( - data["X_labeled"], data["y_labeled"], - data["X_pool"], data["y_pool"], - model=model + data["X_labeled"], + data["y_labeled"], + data["X_pool"], + data["y_pool"], + model=model, ) - return {"length_scale": length_scale, - "n_samples": len(data["X_labeled"])} + return {"length_scale": length_scale, "n_samples": len(data["X_labeled"])} + @typeguard.typechecked async def active_learn(*args, n_select: int = 5) -> dict: @@ -206,9 +211,7 @@ async def main(): # Dynamic adjustment: increase samples when uncertainty is low if state.mean_uncertainty and state.mean_uncertainty < 0.15: - learner.set_next_config( - LearnerConfig(active_learn=TaskConfig(kwargs={"n_select": 10})) - ) + learner.set_next_config(LearnerConfig(active_learn=TaskConfig(kwargs={"n_select": 10}))) print("Low uncertainty, selecting 10 samples next") # Custom early stopping (lower than ROSE's threshold of 0.01) diff --git a/examples/uq_active_learn/active_learn.py b/examples/active_learn/uq/active_learn.py similarity index 69% rename from examples/uq_active_learn/active_learn.py rename to examples/active_learn/uq/active_learn.py index 74b24651..f7e91735 100644 --- a/examples/uq_active_learn/active_learn.py +++ b/examples/active_learn/uq/active_learn.py @@ -1,12 +1,12 @@ # active_learn.py +import argparse import json from pathlib import Path + import numpy as np -import argparse def active_learn(home_dir, pool_suffix, samples_suffix, uq_suffix, learner_name): - pool_file = Path(home_dir, learner_name + pool_suffix) samples_file = Path(home_dir, learner_name + samples_suffix) uq_file = Path(home_dir, learner_name + uq_suffix) @@ -20,12 +20,12 @@ def active_learn(home_dir, pool_suffix, samples_suffix, uq_suffix, learner_name) if not samples_file.is_file(): print(f"Samples file {samples_file} does not exist.") return - - with open(pool_file, 'r') as f: + + with open(pool_file) as f: pool_idx = json.load(f) - with open(samples_file, 'r') as f: + with open(samples_file) as f: labeled_idx = json.load(f) - with open(uq_file, 'r') as f: + with open(uq_file) as f: top_idx = json.load(f) # Map best_idx_local to actual pool indices @@ -40,16 +40,23 @@ def active_learn(home_dir, pool_suffix, samples_suffix, uq_suffix, learner_name) mask[top_idx] = False pool_idx = pool_idx[mask] - with open(samples_file, 'w') as f: + with open(samples_file, "w") as f: json.dump(labeled_idx.tolist(), f) - with open(pool_file, 'w') as f: + with open(pool_file, "w") as f: json.dump(pool_idx.tolist(), f) - print('Active learner picked {} indices for next iteration.'.format(len(labeled_idx))) + print(f"Active learner picked {len(labeled_idx)} indices for next iteration.") + if __name__ == "__main__": parser = argparse.ArgumentParser(description="Prediction argument parser") - parser.add_argument('--learner_name', type=str, help='Name of the learner') - parser.add_argument('--home_dir', type=str, help='Home directory for the project') + parser.add_argument("--learner_name", type=str, help="Name of the learner") + parser.add_argument("--home_dir", type=str, help="Home directory for the project") args = parser.parse_args() - active_learn(args.home_dir, "_pool.json", "_samples.json", "_uq_selection.json", learner_name=args.learner_name) + active_learn( + args.home_dir, + "_pool.json", + "_samples.json", + "_uq_selection.json", + learner_name=args.learner_name, + ) diff --git a/examples/uq_active_learn/check_accuracy.py b/examples/active_learn/uq/check_accuracy.py similarity index 61% rename from examples/uq_active_learn/check_accuracy.py rename to examples/active_learn/uq/check_accuracy.py index 53707606..e235d44c 100644 --- a/examples/uq_active_learn/check_accuracy.py +++ b/examples/active_learn/uq/check_accuracy.py @@ -1,26 +1,29 @@ # check_stop.py import argparse -import random +import random def check_stop(home_dir, model_name): try: + from pathlib import Path + import torch - from torchvision import datasets, transforms + from models import BayesianNN, MC_Dropout_CNN, MC_Dropout_MLP from torch.utils.data import DataLoader - from models import MC_Dropout_CNN, BayesianNN, MC_Dropout_MLP - from pathlib import Path + from torchvision import datasets, transforms + model_file = Path(home_dir, model_name + ".pt") transform = transforms.Compose([transforms.ToTensor()]) - data_dir = Path(home_dir, 'mnist_data') + data_dir = Path(home_dir, "mnist_data") try: - mnist_test = datasets.MNIST(root=data_dir, - train=False, - transform=transform, - download=False # Do NOT download again - ) - except: + mnist_test = datasets.MNIST( + root=data_dir, + train=False, + transform=transform, + download=False, # Do NOT download again + ) + except Exception: print(random.random()) return # mnist_test = datasets.MNIST(root=data_dir, @@ -31,22 +34,20 @@ def check_stop(home_dir, model_name): test_loader = DataLoader(mnist_test, batch_size=64, shuffle=True) # Recreate model architecture - if model_name == 'MC_Dropout_CNN': + if model_name == "MC_Dropout_CNN": model = MC_Dropout_CNN() # Or your model class - elif model_name == 'BayesianNN': - model = BayesianNN() - elif model_name == 'MC_Dropout_MLP': + elif model_name == "BayesianNN": + model = BayesianNN() + elif model_name == "MC_Dropout_MLP": model = MC_Dropout_MLP() else: - #print(f"Model {model_name} not recognized. Please use BayesianNN, MC_Dropout_CNN or MC_Dropout_MLP.") print(random.random()) return - + # Load weights try: model.load_state_dict(torch.load(model_file)) - except Exception as e: - #print(f"Error loading model weights: {model_name} not saved to {model_file}. Error: {e}") + except Exception: print(random.random()) return @@ -61,17 +62,18 @@ def check_stop(home_dir, model_name): correct += (pred == y).sum().item() if n == 10: break - acc = correct / tot + acc = correct / tot - #Return accuracy to pipeline executor. + # Return accuracy to pipeline executor. print(acc) - except: + except Exception: # In case of any error, return a random float between 0 and 1 print(random.random()) + if __name__ == "__main__": parser = argparse.ArgumentParser(description="Prediction argument parser") - parser.add_argument('--model_name', type=str, help='Name of the model used for training') - parser.add_argument('--home_dir', type=str, help='Home directory for the project') + parser.add_argument("--model_name", type=str, help="Name of the model used for training") + parser.add_argument("--home_dir", type=str, help="Home directory for the project") args = parser.parse_args() check_stop(args.home_dir, model_name=args.model_name) diff --git a/examples/uq_active_learn/check_uq.py b/examples/active_learn/uq/check_uq.py similarity index 59% rename from examples/uq_active_learn/check_uq.py rename to examples/active_learn/uq/check_uq.py index 1bd8f2e8..8394d24b 100644 --- a/examples/uq_active_learn/check_uq.py +++ b/examples/active_learn/uq/check_uq.py @@ -1,10 +1,13 @@ -from pathlib import Path -import numpy as np +import argparse import json import sys -import argparse +from pathlib import Path + +import numpy as np + from rose.uq import UQScorer, register_uq + @register_uq("custom_uq") def confidence_score(self, mc_preds): """ @@ -12,13 +15,12 @@ def confidence_score(self, mc_preds): Lower max prob = higher uncertainty. """ mc_preds, _ = self._validate_inputs(mc_preds) - mean_probs = np.mean(mc_preds, axis=0) # [n_instances, n_classes] + mean_probs = np.mean(mc_preds, axis=0) # [n_instances, n_classes] max_prob = np.max(mean_probs, axis=1) return 1.0 - max_prob def check_uq(home_dir, predict_dir, learner_name, query_size, uq_metric_name, task_type): - prediction_dir = Path(home_dir, predict_dir) all_preds = [] @@ -40,25 +42,40 @@ def check_uq(home_dir, predict_dir, learner_name, query_size, uq_metric_name, ta except ValueError: all_preds = np.array(all_preds, dtype=object) # fallback to ragged array - if len(all_preds) == 0: print(sys.float_info.max) else: uq = UQScorer(task_type=task_type) - top_idx_local, uq_metric = uq.select_top_uncertain(all_preds, k=query_size, metric=uq_metric_name) + top_idx_local, uq_metric = uq.select_top_uncertain( + all_preds, k=query_size, metric=uq_metric_name + ) - with open(Path(home_dir, learner_name + '_uq_selection.json'), 'w') as f: + with open(Path(home_dir, learner_name + "_uq_selection.json"), "w") as f: json.dump(top_idx_local.tolist(), f) print(np.mean(uq_metric)) + if __name__ == "__main__": parser = argparse.ArgumentParser(description="Prediction argument parser") - parser.add_argument('--predict_dir', type=str, help='Directory for predictions') - parser.add_argument('--query_size', type=int, help='Size of the query for uncertainty sampling') - parser.add_argument('--uq_metric_name', type=str, help='Name of the uncertainty quantification metric') - parser.add_argument('--task_type', type=str, help='Type of the task for uncertainty quantification') - parser.add_argument('--learner_name', type=str, help='Name of the learner') - parser.add_argument('--home_dir', type=str, help='Home directory for the project') + parser.add_argument("--predict_dir", type=str, help="Directory for predictions") + parser.add_argument("--query_size", type=int, help="Size of the query for uncertainty sampling") + parser.add_argument( + "--uq_metric_name", + type=str, + help="Name of the uncertainty quantification metric", + ) + parser.add_argument( + "--task_type", type=str, help="Type of the task for uncertainty quantification" + ) + parser.add_argument("--learner_name", type=str, help="Name of the learner") + parser.add_argument("--home_dir", type=str, help="Home directory for the project") args = parser.parse_args() - check_uq(args.home_dir, args.predict_dir, args.learner_name, query_size=args.query_size, uq_metric_name=args.uq_metric_name, task_type=args.task_type) \ No newline at end of file + check_uq( + args.home_dir, + args.predict_dir, + args.learner_name, + query_size=args.query_size, + uq_metric_name=args.uq_metric_name, + task_type=args.task_type, + ) diff --git a/examples/uq_active_learn/models.py b/examples/active_learn/uq/models.py similarity index 85% rename from examples/uq_active_learn/models.py rename to examples/active_learn/uq/models.py index fc3326f5..244012b8 100644 --- a/examples/uq_active_learn/models.py +++ b/examples/active_learn/uq/models.py @@ -3,7 +3,7 @@ import torch.nn.functional as F -#First example of non-deterministic model +# First example of non-deterministic model class MC_Dropout_CNN(nn.Module): def __init__(self, dropout_p=0.5): super().__init__() @@ -28,14 +28,16 @@ def forward(self, x): # Second example of non-deterministic model class MC_Dropout_MLP(nn.Module): - def __init__(self, dropout_p=0.5, input_size=28*28, hidden_sizes=[256, 128], num_classes=10): + def __init__(self, dropout_p=0.5, input_size=28 * 28, hidden_sizes=None, num_classes=10): + if hidden_sizes is None: + hidden_sizes = [256, 128] super().__init__() - + self.fc1 = nn.Linear(input_size, hidden_sizes[0]) self.fc2 = nn.Linear(hidden_sizes[0], hidden_sizes[1]) self.dropout1 = nn.Dropout(dropout_p) self.fc3 = nn.Linear(hidden_sizes[1], num_classes) - + def forward(self, x): x = x.view(x.size(0), -1) # Flatten input x = F.relu(self.fc1(x)) @@ -44,7 +46,8 @@ def forward(self, x): x = self.fc3(x) return x -# Bayesian base model + +# Bayesian base model class BayesianLinear(nn.Module): def __init__(self, in_features, out_features, prior_std=1.0): super().__init__() @@ -75,11 +78,16 @@ def forward(self, input): def kl_divergence(self): # KL divergence between learned weight distribution and standard normal prior - kld_weight = -0.5 * torch.sum(1 + self.weight_logvar - self.weight_mu.pow(2) - self.weight_logvar.exp()) - kld_bias = -0.5 * torch.sum(1 + self.bias_logvar - self.bias_mu.pow(2) - self.bias_logvar.exp()) + kld_weight = -0.5 * torch.sum( + 1 + self.weight_logvar - self.weight_mu.pow(2) - self.weight_logvar.exp() + ) + kld_bias = -0.5 * torch.sum( + 1 + self.bias_logvar - self.bias_mu.pow(2) - self.bias_logvar.exp() + ) return kld_weight + kld_bias -# Bayesian model + +# Bayesian model class BayesianNN(nn.Module): def __init__(self, input_size=784, hidden_size=256, output_size=10): super().__init__() diff --git a/examples/uq_active_learn/predict.py b/examples/active_learn/uq/predict.py similarity index 58% rename from examples/uq_active_learn/predict.py rename to examples/active_learn/uq/predict.py index e613e372..0e856a3b 100644 --- a/examples/uq_active_learn/predict.py +++ b/examples/active_learn/uq/predict.py @@ -1,16 +1,26 @@ # active_learn.py +import argparse import json from pathlib import Path + import numpy as np -import argparse QUERY_SIZE = 1 -def prediction(home_dir, pool_suffix, predict_suffix, model_name, prediction_dir, iteration, learner_name): + +def prediction( + home_dir, + pool_suffix, + predict_suffix, + model_name, + prediction_dir, + iteration, + learner_name, +): # Load samples from pool and make predictions predict_dir = Path(home_dir, prediction_dir) - predict_file = Path(predict_dir, str(iteration) + '_' + model_name + predict_suffix) + predict_file = Path(predict_dir, str(iteration) + "_" + model_name + predict_suffix) # Remove all predictions from previous iteration. if predict_dir.is_dir(): @@ -25,38 +35,44 @@ def prediction(home_dir, pool_suffix, predict_suffix, model_name, prediction_dir try: import torch - from torchvision import datasets, transforms - from torch.utils.data import DataLoader, Subset import torch.nn.functional as F - from models import MC_Dropout_CNN, BayesianNN, MC_Dropout_MLP + from models import BayesianNN, MC_Dropout_CNN, MC_Dropout_MLP + from torch.utils.data import DataLoader, Subset + from torchvision import datasets, transforms - - model_file = Path(home_dir, f'{model_name}.pt') + model_file = Path(home_dir, f"{model_name}.pt") transform = transforms.Compose([transforms.ToTensor()]) - full_train = datasets.MNIST(root="./mnist_data", train=True, download=True, transform=transform) + full_train = datasets.MNIST( + root="./mnist_data", train=True, download=True, transform=transform + ) pool_file = Path(home_dir, learner_name + pool_suffix) - with open(pool_file, 'r') as f: + with open(pool_file) as f: pool_idx = json.load(f) pool_loader = DataLoader(Subset(full_train, pool_idx), batch_size=64, shuffle=True) # Recreate model architecture - if model_name == 'MC_Dropout_CNN': - model = MC_Dropout_CNN() - elif model_name == 'BayesianNN': + if model_name == "MC_Dropout_CNN": + model = MC_Dropout_CNN() + elif model_name == "BayesianNN": model = BayesianNN() - elif model_name == 'MC_Dropout_MLP': + elif model_name == "MC_Dropout_MLP": model = MC_Dropout_MLP() else: - print(f"Model {model_name} not recognized. Please use BayesianNN, MC_Dropout_CNN, or MC_Dropout_MLP.") + print( + f"Model {model_name} not recognized. " + "Please use BayesianNN, MC_Dropout_CNN, or MC_Dropout_MLP." + ) return # Load weights try: model.load_state_dict(torch.load(model_file)) except Exception as e: - print(f"Error loading model weights: {model_name} not saved to {model_file}. Error: {e}") + print( + f"Error loading model weights: {model_name} not saved to {model_file}. Error: {e}" + ) return device = torch.device("cuda" if torch.cuda.is_available() else "cpu") @@ -74,7 +90,7 @@ def prediction(home_dir, pool_suffix, predict_suffix, model_name, prediction_dir break all_preds.append(np.vstack(batch_preds)) all_preds = np.array(all_preds) - except: + except Exception: # In case of any error, create dummy predictions print(f"Model {model_name} skipped predictions...") all_preds = np.ones((QUERY_SIZE, 640, 10)) * 0.1 # Dummy predictions if anything fails @@ -85,11 +101,23 @@ def prediction(home_dir, pool_suffix, predict_suffix, model_name, prediction_dir if __name__ == "__main__": parser = argparse.ArgumentParser(description="Prediction argument parser") - parser.add_argument('--model_name', type=str, help='Name of the model used for training') - parser.add_argument('--prediction_dir', type=str, help='Directory for predictions') - parser.add_argument('--iteration', type=str, help='Prediction iteration number for current model name') - parser.add_argument('--learner_name', type=str, help='Name of the learner') - parser.add_argument('--home_dir', type=str, help='Home directory for the project') + parser.add_argument("--model_name", type=str, help="Name of the model used for training") + parser.add_argument("--prediction_dir", type=str, help="Directory for predictions") + parser.add_argument( + "--iteration", + type=str, + help="Prediction iteration number for current model name", + ) + parser.add_argument("--learner_name", type=str, help="Name of the learner") + parser.add_argument("--home_dir", type=str, help="Home directory for the project") args = parser.parse_args() - prediction(args.home_dir, "_pool.json", "_predict.npy", model_name=args.model_name, prediction_dir=args.prediction_dir, iteration=args.iteration, learner_name=args.learner_name) + prediction( + args.home_dir, + "_pool.json", + "_predict.npy", + model_name=args.model_name, + prediction_dir=args.prediction_dir, + iteration=args.iteration, + learner_name=args.learner_name, + ) diff --git a/examples/active_learn/uq/run_me.py b/examples/active_learn/uq/run_me.py new file mode 100644 index 00000000..ad3025f8 --- /dev/null +++ b/examples/active_learn/uq/run_me.py @@ -0,0 +1,196 @@ +# run_me.py +import asyncio +import json +import os +import subprocess +import sys +from concurrent.futures import ProcessPoolExecutor +from pathlib import Path + +from radical.asyncflow import WorkflowEngine +from rhapsody.backends import ConcurrentExecutionBackend, RadicalExecutionBackend + +from rose import TaskConfig +from rose.metrics import MODEL_ACCURACY, PREDICTIVE_ENTROPY +from rose.uq.uq_active_learner import ParallelUQLearner +from rose.uq.uq_learner import UQLearnerConfig + +TEST_RADICAL = False +TEST_CUSTOM_UQ = False + +if TEST_CUSTOM_UQ: + UQ_METRIC_NAME = "custom_uq" # if you want to use custom metric defined in check_uq.py +else: + UQ_METRIC_NAME = PREDICTIVE_ENTROPY + + +ACC_THRESHOLD = 0.5 +UQ_THRESHOLD = 1.5 +ITERATIONS = 3 +PIPELINES = ["UQ1", "UQ2"] +TASK_TYPE = "classification" +USECASE = "ENSEMBLE" +# Options: 'Bayesian', 'SINGLE_MODEL', 'ENSEMBLE' +UQ_QUERY_SIZE = 1 + +home_dir = os.environ.get("ROSE_HOME", subprocess.check_output(["pwd"], text=True).strip()) + + +async def uq_learner(): + if USECASE == "Bayesian": + NUM_PREDICTION = 1 + MODELS = ["BayesianNN"] + elif USECASE == "SINGLE_MODEL": + NUM_PREDICTION = 2 + MODELS = ["MC_Dropout_CNN"] + elif USECASE == "ENSEMBLE": + NUM_PREDICTION = 2 + MODELS = ["MC_Dropout_CNN", "MC_Dropout_MLP"] + else: + return + + if TEST_RADICAL: + RESOURCES = { + "runtime": 300, + "resource": "local.localhost", + #'resource': 'purdue.anvil', + "cores": 16, + } + engine = await RadicalExecutionBackend(RESOURCES) + else: + engine = await ConcurrentExecutionBackend(ProcessPoolExecutor()) + + asyncflow = await WorkflowEngine.create(engine) + + learner = ParallelUQLearner(asyncflow) + code_path = f"{sys.executable} {os.getcwd()}" + + # Define and register the simulation task + @learner.simulation_task() + async def simulation(*args, **kwargs): + learner_name = kwargs.get("--learner_name") + train_batch = kwargs.get("--train_batch") + home_dir = kwargs.get("--home_dir") + return f"{code_path}/simulation.py --train_batch {train_batch} --learner_name {learner_name} --home_dir {home_dir}" # noqa: E501 + + # Define and register the training task for each model + @learner.training_task() + async def training(*args, **kwargs): + learner_name = kwargs.get("--learner_name") + model_name = kwargs.get("--model_name") + epochs = kwargs.get("--epochs") + home_dir = kwargs.get("--home_dir") + return f"{code_path}/training.py --model_name {model_name} --learner_name {learner_name} --epochs {epochs} --home_dir {home_dir}" # noqa: E501 + + # Define and register the predict task for each model + @learner.prediction_task() + async def prediction(*args, **kwargs): + learner_name = kwargs.get("--learner_name") + model_name = kwargs.get("--model_name") + iteration = kwargs.get("--iteration") + prediction_dir = kwargs.get("--prediction_dir") + home_dir = kwargs.get("--home_dir") + return ( + f"{code_path}/predict.py --model_name {model_name} " + f"--prediction_dir {prediction_dir} " + f"--iteration {iteration} --learner_name {learner_name} --home_dir {home_dir}" + ) + + # Define and register the active learning task with UQ metrics + @learner.active_learn_task() + async def active_learn(*args, **kwargs): + learner_name = kwargs.get("--learner_name") + home_dir = kwargs.get("--home_dir") + return f"{code_path}/active_learn.py --learner_name {learner_name} --home_dir {home_dir}" + + # Defining the stop criterion with a metric (MODEL_ACCURACY in this case) + @learner.as_stop_criterion(metric_name=MODEL_ACCURACY, threshold=ACC_THRESHOLD) + async def check_accuracy(*args, **kwargs): + model_name = kwargs.get("--model_name") + home_dir = kwargs.get("--home_dir") + return f"{code_path}/check_accuracy.py --model_name {model_name} --home_dir {home_dir}" + + # Defining the stop criterion with a metric (MODEL_ACCURACY in this case) + @learner.uncertainty_quantification( + uq_metric_name=UQ_METRIC_NAME, threshold=UQ_THRESHOLD, query_size=UQ_QUERY_SIZE + ) + async def check_uq(*args, **kwargs): + home_dir = kwargs.get("--home_dir") + predict_dir = kwargs.get("--prediction_dir") + learner_name = kwargs.get("--learner_name") + query_size = kwargs.get("--query_size") + uq_metric_name = kwargs.get("--uq_metric_name") + task_type = kwargs.get("--task_type") + + return ( + f"{code_path}/check_uq.py --learner_name {learner_name} --predict_dir {predict_dir} " + f"--query_size {query_size} " + f"--uq_metric_name {uq_metric_name} --task_type {task_type} --home_dir {home_dir}" + ) + + learner_configs = {} + for PIPELINE in PIPELINES: + learner_configs[PIPELINE] = UQLearnerConfig( + simulation=TaskConfig( + kwargs={ + "--home_dir": home_dir, + "--train_batch": 50, + "--learner_name": f"{PIPELINE}", + } + ), + training=TaskConfig( + kwargs={ + "--epochs": 10, + "--home_dir": home_dir, + "--learner_name": f"{PIPELINE}", + } + ), + prediction=TaskConfig( + kwargs={ + "--home_dir": home_dir, + "--learner_name": f"{PIPELINE}", + "--prediction_dir": f"{PIPELINE}_prediction", + } + ), + uncertainty=TaskConfig( + kwargs={ + "--uq_metric_name": UQ_METRIC_NAME, + "--task_type": TASK_TYPE, + "--query_size": UQ_QUERY_SIZE, + "--learner_name": f"{PIPELINE}", + "--home_dir": home_dir, + "--prediction_dir": f"{PIPELINE}_prediction", + } + ), + criterion=TaskConfig(kwargs={"--prediction_dir": f"{PIPELINE}_prediction"}), + active_learn=TaskConfig( + kwargs={ + "--learner_name": f"{PIPELINE}", + "--home_dir": home_dir, + } + ), + ) + + # Start the UQ active learning process + final_states = {} + async for state in learner.start( + learner_names=PIPELINES, + model_names=MODELS, + learner_configs=learner_configs, + max_iter=ITERATIONS, + num_predictions=NUM_PREDICTION, + ): + print(f"Learner {state.learner_id}, iteration {state.iteration}: {state.metric_value}") + final_states[state.learner_id] = state + + print("Learning process is done.") + + results = {lid: s.to_dict() for lid, s in final_states.items()} + with open(Path(os.getcwd(), "UQ_training_results.json"), "w") as f: + json.dump(results, f, indent=4) + + await learner.shutdown() + + +if __name__ == "__main__": + asyncio.run(uq_learner()) diff --git a/examples/active_learn/uq/simulation.py b/examples/active_learn/uq/simulation.py new file mode 100644 index 00000000..c635d7d5 --- /dev/null +++ b/examples/active_learn/uq/simulation.py @@ -0,0 +1,59 @@ +# simulation.py +import argparse +import json +from pathlib import Path + +import numpy as np + + +def simulate(home_dir, samples_suffix, pool_suffix, train_batch, learner_name): + pool_file = Path(home_dir, learner_name + pool_suffix) + samples_file = Path(home_dir, learner_name + samples_suffix) + + try: + from torchvision import datasets, transforms + + data_dir = Path(home_dir, "mnist_data") + transform = transforms.Compose([transforms.ToTensor()]) + mnist_train = datasets.MNIST( + root=data_dir, + train=True, + transform=transform, + download=False, # Do NOT download again + ) + indices = np.arange(len(mnist_train)) + except Exception: + # In case of any error, create dummy indices + print(f"Load dummy indices {train_batch * 2} initial samples.") + indices = np.arange(train_batch * 2) + + np.random.seed(42) + np.random.shuffle(indices) + + X_labeled_idx = indices[:train_batch] + X_pool_idx = indices[train_batch:] + + with open(samples_file, "w") as f: + json.dump(X_labeled_idx.tolist(), f) + with open(pool_file, "w") as f: + json.dump(X_pool_idx.tolist(), f) + + print(f"Selected {train_batch} initial samples.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Prediction argument parser") + parser.add_argument( + "--train_batch", type=int, help="Number of labels used for initial training" + ) + parser.add_argument("--learner_name", type=str, help="Name of the learner") + parser.add_argument("--home_dir", type=str, help="Home directory for the project") + args = parser.parse_args() + + simulate( + args.home_dir, + "_samples.json", + "_pool.json", + train_batch=args.train_batch, + learner_name=args.learner_name, + ) diff --git a/examples/uq_active_learn/training.py b/examples/active_learn/uq/training.py similarity index 66% rename from examples/uq_active_learn/training.py rename to examples/active_learn/uq/training.py index f7f39297..fab7ab0b 100644 --- a/examples/uq_active_learn/training.py +++ b/examples/active_learn/uq/training.py @@ -1,39 +1,47 @@ # training.py -import time import argparse +import time -def train_model(home_dir, samples_suffix, model_name, learner_name, epochs=1): +def train_model(home_dir, samples_suffix, model_name, learner_name, epochs=1): try: - from torchvision import datasets, transforms + import json + from pathlib import Path + import torch import torch.nn as nn - import torch.nn.functional as F + from models import BayesianNN, MC_Dropout_CNN, MC_Dropout_MLP, elbo_loss from torch.utils.data import DataLoader, Subset - from pathlib import Path - from models import MC_Dropout_CNN, BayesianNN, MC_Dropout_MLP, elbo_loss - import json + from torchvision import datasets, transforms - model_file = Path(home_dir, f'{model_name}.pt') + model_file = Path(home_dir, f"{model_name}.pt") transform = transforms.Compose([transforms.ToTensor()]) - full_train = datasets.MNIST(root=Path(home_dir, "mnist_data"), train=True, download=True, transform=transform) - + full_train = datasets.MNIST( + root=Path(home_dir, "mnist_data"), + train=True, + download=True, + transform=transform, + ) + samples_file = Path(home_dir, learner_name + samples_suffix) - with open(samples_file, 'r') as f: + with open(samples_file) as f: labeled_idx = json.load(f) loader = DataLoader(Subset(full_train, labeled_idx), batch_size=64, shuffle=True) - + # Recreate model architecture - if model_name == 'MC_Dropout_CNN': + if model_name == "MC_Dropout_CNN": model = MC_Dropout_CNN() # Or your model class - elif model_name == 'BayesianNN': + elif model_name == "BayesianNN": model = BayesianNN() - elif model_name == 'MC_Dropout_MLP': + elif model_name == "MC_Dropout_MLP": model = MC_Dropout_MLP() else: - print(f"Model {model_name} not recognized. Please use BayesianNN, MC_Dropout_CNN or MC_Dropout_MLP.") + print( + f"Model {model_name} not recognized. " + "Please use BayesianNN, MC_Dropout_CNN or MC_Dropout_MLP." + ) return - + # Load weights from previous iteration of training try: model.load_state_dict(torch.load(model_file)) @@ -56,8 +64,8 @@ def train_model(home_dir, samples_suffix, model_name, learner_name, epochs=1): optimizer.zero_grad() output = model(X) - - if 'bayesian' in model_name.lower(): + + if "bayesian" in model_name.lower(): # Use ELBO loss for Bayesian models kl = model.kl_loss() loss = elbo_loss(output, y, kl, kl_weight=1.0 / len(loader)) @@ -72,21 +80,28 @@ def train_model(home_dir, samples_suffix, model_name, learner_name, epochs=1): # Example: save model weights torch.save(model.state_dict(), model_file) - except: + except Exception: # In case of any error, just wait print(f"Skip Training for {model_name} due to error.") for _ in range(10): time.sleep(1) print("sleeping...") + if __name__ == "__main__": parser = argparse.ArgumentParser(description="Prediction argument parser") - parser.add_argument('--model_name', type=str, help='Name of the model used for training') - parser.add_argument('--learner_name', type=str, help='Name of the learner') - parser.add_argument('--epochs', type=int, help='Number of epochs') - parser.add_argument('--home_dir', type=str, help='Home directory for the project') + parser.add_argument("--model_name", type=str, help="Name of the model used for training") + parser.add_argument("--learner_name", type=str, help="Name of the learner") + parser.add_argument("--epochs", type=int, help="Number of epochs") + parser.add_argument("--home_dir", type=str, help="Home directory for the project") args = parser.parse_args() - #print(args) + # print(args) - train_model(home_dir=args.home_dir, samples_suffix="_samples.json", model_name=args.model_name, learner_name=args.learner_name, epochs=args.epochs) + train_model( + home_dir=args.home_dir, + samples_suffix="_samples.json", + model_name=args.model_name, + learner_name=args.learner_name, + epochs=args.epochs, + ) diff --git a/examples/active_learn/uq_based_al/active.py b/examples/active_learn/uq_based_al/active.py deleted file mode 100644 index 913c99d0..00000000 --- a/examples/active_learn/uq_based_al/active.py +++ /dev/null @@ -1,55 +0,0 @@ -# acl.py -import pickle -import numpy as np -from sklearn.linear_model import LinearRegression -from sklearn.metrics import mean_squared_error -from rose.metrics import PREDICTIVE_ENTROPY -from rose.uq import UQScorer - -def acl(input_file='train_output.pkl', output_file='acl_output.pkl'): - # Load model and data - with open(input_file, 'rb') as f: - model = pickle.load(f) - - with open('sim_output.pkl', 'rb') as f: - (X_labeled, y_labeled), X_unlabeled = pickle.load(f) - - # Predict on the unlabeled data to find the most uncertain samples - y_pred_unlabeled = model.predict(X_unlabeled) - - uq = UQScorer(task_type='classification') - # Select the most uncertain data points (e.g., top 10% most uncertain) - uncertain_indices, uq_metric = uq.select_top_uncertain(y_pred_unlabeled, k=10, metric=PREDICTIVE_ENTROPY) - - X_selected = X_unlabeled[uncertain_indices] - y_selected = 2 * X_selected + 1 + np.random.normal(0, 0.1, X_selected.shape) # Simulate labels for selected data - - # Ensure X_selected is 2D (flatten if necessary) - if X_selected.ndim == 3: - X_selected = X_selected.reshape(X_selected.shape[0], -1) # Flatten to 2D if needed - - # Similarly for y_selected if necessary - if y_selected.ndim == 3: - y_selected = y_selected.reshape(y_selected.shape[0], -1) - - # Add selected uncertain data to labeled set - X_labeled = np.vstack([X_labeled, X_selected]) - y_labeled = np.vstack([y_labeled, y_selected]) - - # Retrain model with updated labeled data - model.fit(X_labeled, y_labeled) - - # Evaluate retrained model - y_pred = model.predict(X_labeled) - mse = mean_squared_error(y_labeled, y_pred) - - print(f"Active Learning completed. MSE: {mse:.4f} UQ: {np.mean(uq_metric):.4f}") - - # Save the updated model and the new labeled data - with open(output_file, 'wb') as f: - pickle.dump(model, f) - - return output_file, mse - -if __name__ == "__main__": - acl() # Running the active learning task diff --git a/examples/active_learn/uq_based_al/basic-tutorial.ipynb b/examples/active_learn/uq_based_al/basic-tutorial.ipynb deleted file mode 100644 index 1fa1b1d5..00000000 --- a/examples/active_learn/uq_based_al/basic-tutorial.ipynb +++ /dev/null @@ -1,314 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "f4f14e3c-1e45-4c07-9d3c-d48be14978af", - "metadata": {}, - "source": [ - "# This notebook will show how to use the ROSE framework to run an Active Learning workflow" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6f5a2778-4eef-415a-b9ec-65f5d28bfe8e", - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "import sys\n", - "\n", - "from rose.metrics import MEAN_SQUARED_ERROR_MSE\n", - "from rose.al.active_learner import SequentialActiveLearner\n", - "\n", - "from radical.asyncflow import WorkflowEngine\n", - "from radical.asyncflow import RadicalExecutionBackend" - ] - }, - { - "cell_type": "markdown", - "id": "a81db849-c634-4971-8dce-1f10b5eb003e", - "metadata": {}, - "source": [ - "### Let us first declare the resource engine for our active learning tasks.\n", - "We will ask for 30 minutes, and the target resources will be local, which means it will run on the user's machine.\n", - "\n", - "Next, we define the active learner and assign the resource engine." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8c44487b-0029-4b2e-b325-6131b2ed2f6f", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Resource Engine started successfully\n", - "\n" - ] - } - ], - "source": [ - "engine = await RadicalExecutionBackend({'resource': 'local.localhost'})\n", - "asyncflow = await WorkflowEngine.create(engine)\n", - "\n", - "acl = SequentialActiveLearner(asyncflow)\n", - "\n", - "code_path = f'{sys.executable} {os.getcwd()}'" - ] - }, - { - "cell_type": "markdown", - "id": "e7d0172d-ffc5-4d5d-bec0-4d4805b680da", - "metadata": {}, - "source": [ - "### Now, let us define our active learning tasks: simulation, training, and active learning" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c1184cb7-e140-4102-8e25-c2a0c8045b2d", - "metadata": {}, - "outputs": [], - "source": [ - "# Define and register the simulation task\n", - "@acl.simulation_task\n", - "async def simulation(*args):\n", - " return f'{code_path}/sim.py'\n", - "\n", - "# Define and register the training task\n", - "@acl.training_task\n", - "async def training(*args):\n", - " return f'{code_path}/train.py'\n", - "\n", - "# Define and register the active learning task\n", - "@acl.active_learn_task\n", - "async def active_learn(*args):\n", - " return f'{code_path}/active.py'" - ] - }, - { - "cell_type": "markdown", - "id": "83a8ba45-9e28-46c8-8630-b0ad997fdb60", - "metadata": {}, - "source": [ - "Optionally, we can define a stop criterion, which will be invoked on every iteration of the Active learning loop and break the iterations if it is satisfied." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "26b89932-86ae-4fc7-81e3-39b57522430d", - "metadata": {}, - "outputs": [], - "source": [ - "# Defining the stop criterion with a metric (MSE in this case)\n", - "@acl.as_stop_criterion(metric_name=MEAN_SQUARED_ERROR_MSE, threshold=0.001)\n", - "async def check_mse(*args):\n", - " return f'{code_path}/check_mse.py'" - ] - }, - { - "cell_type": "markdown", - "id": "b13550f5", - "metadata": {}, - "source": [ - "
NOTE: ROSE supports a variety of `METRICS`, which can be used to evaluate the model's performance. However, it also supports custom metrics that users can define when they specify the criterion function.
\n", - "\n", - "\n", - "\n", - "
WARNING: For custom metrics, users must specify the `operator` field as below:
\n", - "\n", - "```python\n", - "from rose.metrics import LESS_THAN_THRESHOLD\n", - "@acl.as_stop_criterion(metric_name='metric_x',\n", - " operator=LESS_THAN_THRESHOLD, threshold=0.001)\n", - "async def check_metric_x(*args):\n", - " return f'{code_path}/check_metric_x.py'\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "dcbada25-03ec-4b6c-bc52-081c9358ce0f", - "metadata": {}, - "source": [ - "### Now let us invoke the tasks to build the active learning workflow\n", - "Once we invoke the `teach` method, the ROSE builds the AL workflow and starts the learning process" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bcd3cdf2-2edc-468b-805c-286bb9291197", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Registered task 'simulation' and id of 000000 with dependencies: []\n", - "Registered task 'training' and id of 000001 with dependencies: ['simulation']\n", - "Starting Iteration-0\n", - "Registered task 'active_learn' and id of 000002 with dependencies: ['simulation', 'training']\n", - "Registered task 'check_mse' and id of 000003 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000004 with dependencies: ['active_learn']\n", - "Registered task 'training' and id of 000005 with dependencies: ['simulation']\n", - "Starting Iteration-1\n", - "Registered task 'active_learn' and id of 000006 with dependencies: ['simulation', 'training']\n", - "Registered task 'check_mse' and id of 000007 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000008 with dependencies: ['active_learn']\n", - "Registered task 'training' and id of 000009 with dependencies: ['simulation']\n", - "Starting Iteration-2\n", - "Registered task 'active_learn' and id of 000010 with dependencies: ['simulation', 'training']\n", - "Registered task 'check_mse' and id of 000011 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000012 with dependencies: ['active_learn']\n", - "Registered task 'training' and id of 000013 with dependencies: ['simulation']\n", - "Starting Iteration-3\n", - "Registered task 'active_learn' and id of 000014 with dependencies: ['simulation', 'training']\n", - "Registered task 'check_mse' and id of 000015 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000016 with dependencies: ['active_learn']\n", - "Registered task 'training' and id of 000017 with dependencies: ['simulation']\n", - "Starting Iteration-4\n", - "Registered task 'active_learn' and id of 000018 with dependencies: ['simulation', 'training']\n", - "Registered task 'check_mse' and id of 000019 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000020 with dependencies: ['active_learn']\n", - "Registered task 'training' and id of 000021 with dependencies: ['simulation']\n", - "Starting Iteration-5\n", - "Registered task 'active_learn' and id of 000022 with dependencies: ['simulation', 'training']\n", - "Registered task 'check_mse' and id of 000023 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000024 with dependencies: ['active_learn']\n", - "Registered task 'training' and id of 000025 with dependencies: ['simulation']\n", - "Starting Iteration-6\n", - "Registered task 'active_learn' and id of 000026 with dependencies: ['simulation', 'training']\n", - "Registered task 'check_mse' and id of 000027 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000028 with dependencies: ['active_learn']\n", - "Registered task 'training' and id of 000029 with dependencies: ['simulation']\n", - "Starting Iteration-7\n", - "Registered task 'active_learn' and id of 000030 with dependencies: ['simulation', 'training']\n", - "Registered task 'check_mse' and id of 000031 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000032 with dependencies: ['active_learn']\n", - "Registered task 'training' and id of 000033 with dependencies: ['simulation']\n", - "Starting Iteration-8\n", - "Registered task 'active_learn' and id of 000034 with dependencies: ['simulation', 'training']\n", - "Registered task 'check_mse' and id of 000035 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000036 with dependencies: ['active_learn']\n", - "Registered task 'training' and id of 000037 with dependencies: ['simulation']\n", - "Starting Iteration-9\n", - "Registered task 'active_learn' and id of 000038 with dependencies: ['simulation', 'training']\n", - "Registered task 'check_mse' and id of 000039 with dependencies: ['active_learn']\n", - "Registered task 'simulation' and id of 000040 with dependencies: ['active_learn']\n", - "Registered task 'training' and id of 000041 with dependencies: ['simulation']\n" - ] - } - ], - "source": [ - "# Start the learning process\n", - "await acl.start(max_iter=10)" - ] - }, - { - "cell_type": "markdown", - "id": "af11cd52-0c96-4c0a-8fa8-b77e0dc805bf", - "metadata": {}, - "source": [ - "### Once the learning process is finished, we will make sure to terminate the resources" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e0274192-3c6c-4045-b867-d678e675415b", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Shutdown is triggered, terminating the resources gracefully\n" - ] - } - ], - "source": [ - "# Start the learning process\n", - "await acl.shutdown()" - ] - }, - { - "cell_type": "markdown", - "id": "658e7c4f-9c74-4926-bc94-49e25e2bcc23", - "metadata": {}, - "source": [ - "### To better understand our model performance, we will plot the MSE of each iteration." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f9d2a44f-787b-42ab-8716-1b6244fb4305", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "Text(0.5, 1.0, 'MSE Values for Machine Learning Model')" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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7du3T61bt9batWvTPdfgwYO1Y8eObH39PXv21FdffZX673nq1CktXbo0w/AtZf37vWzZMrVs2VI7duzQkCFDNG7cOLVp00aLFi1yOi45OVmRkZEqXry43n33XbVq1Urjxo3TpEmTslR/t27ddP78eY0ePVrdunXTlClT9Nprrzkd89Zbb+nhhx9WtWrV9N577+mZZ55RTEyMWrZsecOfxaw4fvy4mjRpoh9++EFPPvmk3n//fVWtWlWDBg1K/beUpPj4eP33v/9V69atNWbMGI0aNUonT55UZGSkNm/enO55J0+erA8//FCPPvqoxo0b5xSGs/J1Z+app57Sli1bNHLkSD3++OP69ttv0w13HT58uF577TU1bNhQ77zzjqpVq6bIyEglJCRk63vTq1cvffvtt7pw4YIkcwVvzpw51/356tatmwIDAzV69Gg98sgjio6OVvPmzZ3+rZYuXaquXbvKZrNp9OjRioqK0oABAzKcm/fYY4/phRde0J133qn3339fAwYM0IwZMxQZGakrV65k6+sB4AHsAOBlJk+ebJdkX7dunX3ChAn2QoUK2S9evGi32+32hx56yN6mTRu73W63V6xY0d65c+fUx128eNFevXp1uyR7xYoV7f3797d//vnn9uPHj6d7jZEjR9olZfhRvXr1G9b40Ucf2SXZlyxZkrovOTnZXq5cOXvTpk2davq7xx57zJ4/f3775cuXU/f169fPXrFixdT7y5cvt0uyL1++3Omx+/bts0uyT548OXVf27Zt7XXq1HF6vpSUFHuzZs3s1apVS91Xt25dp+9XVjle85133nHaHxUVZQ8ODrb/+eefqfuOHj1qL1SokL1ly5ap+xz/ns2bN7dfvXo1W6+3bds2uyT76tWr7Xa7+b4XLFjQnpCQYO/Xr5+9QIECTo/Nyvf76tWr9sqVK9srVqxoP3v2rNOxKSkpqdv9+vWzS7K//vrrTsfUq1fP3qBBA6d9kuwjR45Mve/4+Ro4cKDTcQ888IC9ePHiqff3799vDwwMtL/11ltOx23dutUeFBSUbv/fpf1dycygQYPsZcqUsZ86dcppf48ePeyhoaGp37OrV6/aExMTnY45e/asPSwszOnrcPz7FC5c2H7ixAmn47P6ddvt5ve3X79+6b6Wdu3aOf07PPvss/bAwED7uXPn7Ha73R4bG2sPCgqyR0VF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P9WxY8fSPU/aFt2nT592+lzBggVVtWrVDOfC3UhgYKA6dOigb775Rvv370/df/z48dSFsV3V2l+S3nzzTY0cOfK683qy+v2uX7++KleurPHjx6cLt7l1dSUzXbp0UWBgoF577bV0r22329P9m2VXYGBg6npVGZ18SPvzkdH3b+3atVqzZs1N1eBqbdu2VVBQkD755BOn/RMmTMjxcz7//PMaOXJkpkN3Jalhw4YqVaqUJk6c6PQ78/3332vnzp3q3LmzJDPvMSIiQlOnTnUaWrxs2TLt2LHD6Tm7deum5ORkvfHGG+le7+rVqy7pdgnAvbjyBcAnZDZkKq1ly5Zp5MiRuu+++9SkSRMVLFhQf/31l7744gslJiY6rcPkMHfu3AyHdrVv315hYWE3fM0+ffroyy+/1JIlS9S7d2+nhYObNWumokWLql+/fnr66adls9k0bdq0LL3BDw0N1UMPPaQPP/xQNptNVapU0aJFizKcA/LRRx+pefPmqlOnjh555BHdcsstOn78uNasWaPDhw9ry5YtkqSaNWuqdevWatCggYoVK6b169dr7ty56dp4Z9Wbb76pZcuWqXnz5ho8eLCCgoL06aefKjExMdN5MznVqlUrtWrV6rrHZPX7HRAQoE8++UT33nuvIiIiNGDAAJUpU0a7du3S9u3btWTJEpfWfj1VqlTRm2++qeHDh2v//v2KiopSoUKFtG/fPs2fP1+PPvqonn/++Rs+zxdffKHFixen2z9kyBC9/fbbWr58uRo3bqxHHnlENWvW1JkzZ7Rx40b98MMPOnPmjCTpnnvuUXR0tB544AF17txZ+/bt08SJE1WzZs3UVuyeICwsLHV5gPvuu08dO3bUli1b9P3336tEiRKZDtm9nrp16zot5J2RPHnyaMyYMRowYIBatWqlnj176vjx43r//fdVqVIlPfvss6nHjh49Wp07d1bz5s01cOBAnTlzRh9++KFq1arl9L1s1aqVHnvsMY0ePVqbN29Whw4dlCdPHu3Zs0dz5szR+++/f905rgA8D+ELgN/o2rWrzp8/r6VLl+rHH3/UmTNnVLRoUd1xxx167rnn1KZNm3SPefzxxzN8ruXLl2cpfN11110qU6aMjh075jTkUDJNORYtWqTnnntOr7zyiooWLao+ffqobdu2mc7PSuvDDz/UlStXNHHiROXNm1fdunXT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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "import matplotlib.pyplot as plt\n", - "# Convert string values to floats\n", - "mse_values = acl.get_metric_results()\n", - "mse_values = [float(mse.strip()) for mse in mse_values]\n", - "\n", - "# Plot the MSE values\n", - "plt.figure(figsize=(10, 6))\n", - "plt.plot(mse_values, marker='o', color='b', linestyle='-', markersize=6)\n", - "\n", - "# Add labels and title\n", - "plt.xlabel('Index')\n", - "plt.ylabel('MSE Value')\n", - "plt.title('MSE Values for Machine Learning Model')" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/examples/active_learn/uq_based_al/check_mse.py b/examples/active_learn/uq_based_al/check_mse.py deleted file mode 100644 index c509ed4e..00000000 --- a/examples/active_learn/uq_based_al/check_mse.py +++ /dev/null @@ -1,25 +0,0 @@ -# check.py -import sys -import pickle -import numpy as np - -from sklearn.metrics import mean_squared_error - - -def check(input_file='acl_output.pkl'): - # Load the model after active learning - with open(input_file, 'rb') as f: - model = pickle.load(f) - - # Simulate evaluation (in practice, you would use a validation dataset) - # Here, we'll use the same dataset to check performance (for simplicity) - X_eval = np.random.rand(100, 1) # Evaluation data - y_eval = 2 * X_eval + 1 + np.random.normal(0, 0.1, (100, 1)) # Evaluation labels - - # Evaluate the model on the new data - y_pred_eval = model.predict(X_eval) - mse_eval = mean_squared_error(y_eval, y_pred_eval) - print(mse_eval) - -if __name__ == "__main__": - check() # Running the check task diff --git a/examples/active_learn/uq_based_al/run_me.py b/examples/active_learn/uq_based_al/run_me.py deleted file mode 100644 index a4d4acb9..00000000 --- a/examples/active_learn/uq_based_al/run_me.py +++ /dev/null @@ -1,47 +0,0 @@ -import asyncio -import os -import sys - -from radical.asyncflow import RadicalExecutionBackend, WorkflowEngine - -from rose.al.active_learner import SequentialActiveLearner -from rose.metrics import MEAN_SQUARED_ERROR_MSE - - -async def rose_al(): - - engine = await RadicalExecutionBackend({'resource': 'local.localhost'}) - asyncflow = await WorkflowEngine.create(engine) - - acl = SequentialActiveLearner(asyncflow) - code_path = f'{sys.executable} {os.getcwd()}' - - # Define and register the simulation task - @acl.simulation_task - async def simulation(*args): - return f'{code_path}/sim.py' - - # Define and register the training task - @acl.training_task - async def training(*args): - return f'{code_path}/train.py' - - # Define and register the active learning task - @acl.active_learn_task - async def active_learn(*args): - return f'{code_path}/active.py' - - # Defining the stop criterion with a metric (MSE in this case) - @acl.as_stop_criterion(metric_name=MEAN_SQUARED_ERROR_MSE, threshold=0.1) - async def check_mse(*args): - return f'{code_path}/check_mse.py' - - # Start the active learning process - async for state in acl.start(): - print(f"Iteration {state.iteration}: metric={state.metric_value}") - - await acl.shutdown() - - -if __name__ == "__main__": - asyncio.run(rose_al()) diff --git a/examples/active_learn/uq_based_al/sim.py b/examples/active_learn/uq_based_al/sim.py deleted file mode 100644 index fde935b0..00000000 --- a/examples/active_learn/uq_based_al/sim.py +++ /dev/null @@ -1,25 +0,0 @@ -# sim.py -import numpy as np -import pickle - -def sim(output_file='sim_output.pkl'): - # Generate initial labeled data for simulation - X = np.random.rand(100, 1) # 100 samples, 1 feature - y = 2 * X + 1 + np.random.normal(0, 0.1, (100, 1)) # Linear relationship with noise - - # Save the initial labeled data - labeled_data = (X, y) - - # Generate additional unlabeled data for active learning - X_unlabeled = np.random.rand(100, 1) # 100 additional unlabeled samples - unlabeled_data = X_unlabeled - - with open(output_file, 'wb') as f: - pickle.dump((labeled_data, unlabeled_data), f) - - print(f"Simulation completed. Data saved to {output_file}") - return output_file - - -if __name__ == "__main__": - sim() # Running the simulation task diff --git a/examples/active_learn/uq_based_al/train.py b/examples/active_learn/uq_based_al/train.py deleted file mode 100644 index 983911a0..00000000 --- a/examples/active_learn/uq_based_al/train.py +++ /dev/null @@ -1,29 +0,0 @@ -# train.py -import pickle -from sklearn.linear_model import LinearRegression -from sklearn.metrics import mean_squared_error - -def train(input_file='sim_output.pkl', output_file='train_output.pkl'): - # Load labeled data - with open(input_file, 'rb') as f: - (X_labeled, y_labeled), _ = pickle.load(f) - - # Train a simple linear regression model - model = LinearRegression() - model.fit(X_labeled, y_labeled) - - # Predict and compute Mean Squared Error (MSE) as a simple performance metric - y_pred = model.predict(X_labeled) - mse = mean_squared_error(y_labeled, y_pred) - - print(f"Training completed. MSE: {mse:.4f}") - - # Save the trained model - with open(output_file, 'wb') as f: - pickle.dump(model, f) - - print(f"Model saved to {output_file}") - return output_file, mse - -if __name__ == "__main__": - train() # Running the training task diff --git a/examples/integrations/mlflow/README.md b/examples/integrations/mlflow/README.md deleted file mode 100644 index 8bf069b1..00000000 --- a/examples/integrations/mlflow/README.md +++ /dev/null @@ -1,278 +0,0 @@ -# MLflow Integration with ROSE - -This guide shows how to combine ROSE's workflow orchestration with MLflow's experiment tracking for active learning workflows. - -## Why Use Both? - -ROSE and MLflow solve different problems and work well together: - -``` -┌─────────────────────────────────────────────────────────────┐ -│ ROSE (Orchestration) │ -│ ┌──────────┐ ┌──────────┐ ┌─────────────┐ ┌───────┐ │ -│ │Simulation│ → │ Training │ → │Active Learn │ → │ Check │ │ -│ └──────────┘ └──────────┘ └─────────────┘ └───────┘ │ -│ ↓ ↓ ↓ ↓ │ -└────────┼──────────────┼───────────────┼──────────────┼──────┘ - │ │ │ │ - ▼ ▼ ▼ ▼ -┌─────────────────────────────────────────────────────────────┐ -│ MLflow (Tracking) │ -│ • Log parameters • Log metrics • Store models │ -│ • Track iterations • Learning curves • Model registry │ -└─────────────────────────────────────────────────────────────┘ -``` - -| Tool | Role | What It Does | -|------|------|--------------| -| **ROSE** | Orchestration | Manages task execution order, dependencies, HPC resources, iteration loops | -| **MLflow** | Tracking | Records parameters, metrics, models, and artifacts for analysis | - -**ROSE answers:** *What runs? When? Where? In what order?* - -**MLflow answers:** *What happened? How well did it perform? Can I reproduce it?* - -## Installation - -```bash -# Install MLflow -pip install mlflow - -# Optional: for visualization -pip install matplotlib -``` - -## Quick Start - -```bash -# Run the example -python mlflow_rose.py - -# View results in MLflow UI -mlflow ui --port 5000 -``` - -Then open http://localhost:5000 in your browser. - -## Basic Integration Pattern - -Here's the minimal pattern to integrate MLflow with a ROSE active learning workflow: - -```python -import mlflow -from rose.al import SequentialActiveLearner - -async def main(): - # 1. Set up MLflow experiment - mlflow.set_experiment("my_active_learning_experiment") - - with mlflow.start_run(): - # 2. Log your configuration - mlflow.log_params({ - "max_iterations": 10, - "mse_threshold": 0.01, - "n_initial_samples": 10, - }) - - # 3. Set up ROSE learner (as usual) - learner = SequentialActiveLearner(asyncflow) - learner.simulation_task(as_executable=False)(simulation) - learner.training_task(as_executable=False)(training) - learner.active_learn_task(as_executable=False)(active_learn) - learner.as_stop_criterion(...)(check_mse) - - # 4. Run ROSE loop and log metrics at each iteration - async for state in learner.start(max_iter=10): - # ROSE yields state, MLflow records it - mlflow.log_metric("mse", state.metric_value, step=state.iteration) - mlflow.log_metric("labeled_count", state.labeled_count, step=state.iteration) - mlflow.log_metric("uncertainty", state.mean_uncertainty, step=state.iteration) - - # 5. Log final model - mlflow.sklearn.log_model(model, "surrogate_model") -``` - -## What Gets Tracked - -### Parameters (logged once at start) - -```python -mlflow.log_params({ - "max_iterations": 15, - "mse_threshold": 0.02, - "n_initial_samples": 10, - "n_pool_samples": 200, - "n_select_per_iteration": 5, - "orchestrator": "ROSE", - "learner_type": "SequentialActiveLearner", -}) -``` - -### Metrics (logged at each iteration) - -| Metric | Description | -|--------|-------------| -| `mse` | Mean squared error on validation set | -| `train_mse` | Training set MSE | -| `labeled_count` | Number of labeled samples | -| `unlabeled_count` | Remaining pool size | -| `mean_uncertainty` | Average model uncertainty | -| `max_uncertainty` | Maximum uncertainty in pool | - -```python -# Log with step number for iteration tracking -mlflow.log_metric("mse", state.metric_value, step=state.iteration) -``` - -### Final Metrics (logged at end) - -| Metric | Description | -|--------|-------------| -| `final_mse` | Final validation MSE | -| `final_mae` | Final mean absolute error | -| `final_r2` | Final R-squared score | -| `total_iterations` | Number of iterations completed | - -### Artifacts - -- **Models**: Trained surrogate model saved to MLflow Model Registry -- **Plots**: Learning curves (MSE vs iteration, MSE vs sample size) - -```python -# Log model with signature for deployment -mlflow.sklearn.log_model( - model, - artifact_path="surrogate_model", - signature=signature, - registered_model_name="MyModel", -) - -# Log plots -mlflow.log_artifact("learning_curve.png", artifact_path="plots") -``` - -### Tags - -```python -mlflow.set_tags({ - "framework": "ROSE+MLflow", - "task_type": "active_learning", - "model_type": "GaussianProcessRegressor", - "status": "success", # or "failed" -}) -``` - -## Helper Class: MLflowROSETracker - -The example includes a helper class that wraps common MLflow operations: - -```python -class MLflowROSETracker: - def start_experiment(self, config: dict): - """Initialize MLflow run and log parameters.""" - - def log_iteration(self, state: IterationState): - """Log metrics from ROSE iteration state.""" - - def log_model(self, model, X_sample, y_sample): - """Log model to MLflow registry.""" - - def log_final_evaluation(self, model): - """Compute and log final metrics.""" - - def end_experiment(self, success: bool): - """Finalize the MLflow run.""" -``` - -Usage: - -```python -tracker = MLflowROSETracker("my_experiment") -tracker.start_experiment({"max_iterations": 10}) - -async for state in learner.start(max_iter=10): - tracker.log_iteration(state) - -tracker.log_model(model, X_sample, y_sample) -tracker.end_experiment(success=True) -``` - -## Viewing Results - -After running, start the MLflow UI: - -```bash -mlflow ui --port 5000 -``` - -In the UI you can: - -- **Compare runs**: See how different configurations perform -- **View metrics**: Interactive charts of MSE, uncertainty over iterations -- **Download models**: Get trained models from the registry -- **Check artifacts**: View learning curve plots - -## Example Output - -``` -============================================================ -ROSE + MLflow Integration Example -============================================================ - -ROSE: Orchestrates the active learning workflow -MLflow: Tracks experiments, metrics, and models -============================================================ -MLflow Run ID: a1b2c3d4e5f6 -MLflow Experiment: ROSE_Active_Learning - -[ROSE] Starting active learning loop... ------------------------------------------------------------- - -[Iteration 0] - MSE: 0.156234 (threshold: 0.02) - Labeled: 15, Pool: 195 - Uncertainty - mean: 0.2341, max: 0.4521 - -[Iteration 1] - MSE: 0.089123 (threshold: 0.02) - Labeled: 20, Pool: 190 - Uncertainty - mean: 0.1892, max: 0.3891 - -... - ------------------------------------------------------------- -[ROSE] Active learning completed - -[MLflow] Logging final model... -[MLflow] Computing final evaluation metrics... - -============================================================ -Final Results -============================================================ - Total iterations: 8 - Final MSE: 0.018234 - Final MAE: 0.102341 - Final R2: 0.9812 - Final labeled samples: 50 - -MLflow run completed: a1b2c3d4e5f6 - -============================================================ -To view results, run: - mlflow ui --port 5000 -Then open http://localhost:5000 -============================================================ -``` - -## Files - -| File | Description | -|------|-------------| -| `mlflow_rose.py` | Complete integration example with all features | -| `README.md` | This documentation | - -## Additional Resources - -- [MLflow Documentation](https://mlflow.org/docs/latest/index.html) -- [ROSE Documentation](https://radical-cybertools.github.io/ROSE/) -- [MLflow Model Registry](https://mlflow.org/docs/latest/model-registry.html) diff --git a/examples/integrations/mlflow/mlflow_rose.py b/examples/integrations/mlflow/mlflow_rose.py deleted file mode 100644 index 6963b5fe..00000000 --- a/examples/integrations/mlflow/mlflow_rose.py +++ /dev/null @@ -1,546 +0,0 @@ -""" -MLflow + ROSE Integration Example: Complementary Relationship - -This example demonstrates how ROSE and MLflow work together: -- ROSE: Orchestrates the active learning workflow (execution engine) -- MLflow: Tracks experiments, logs metrics, and manages model artifacts (observability) - -ROSE handles WHAT runs and WHEN (workflow orchestration) -MLflow handles WHAT happened and HOW WELL (experiment tracking) - -Requirements: - pip install mlflow scikit-learn numpy - -Usage: - python mlflow_rose.py - - # View results in MLflow UI: - mlflow ui --port 5000 - # Then open http://localhost:5000 -""" - -import asyncio -from concurrent.futures import ProcessPoolExecutor -from pathlib import Path -import pickle -import tempfile -from datetime import datetime - -import numpy as np -from sklearn.gaussian_process import GaussianProcessRegressor -from sklearn.gaussian_process.kernels import RBF, WhiteKernel -from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score - -# MLflow for experiment tracking -import mlflow -from mlflow.models import infer_signature - -# ROSE for workflow orchestration -from radical.asyncflow import ConcurrentExecutionBackend, WorkflowEngine -from rose.al import SequentialActiveLearner -from rose.learner import LearnerConfig, TaskConfig -from rose.metrics import MEAN_SQUARED_ERROR_MSE - - -# ============================================================================= -# Configuration -# ============================================================================= -EXPERIMENT_NAME = "ROSE_Active_Learning" -DATA_FILE = Path(tempfile.gettempdir()) / "rose_mlflow_data.pkl" -MAX_ITERATIONS = 15 -MSE_THRESHOLD = 0.002 -N_INITIAL_SAMPLES = 10 -N_POOL_SAMPLES = 200 -N_SELECT_PER_ITERATION = 5 - - -# ============================================================================= -# Target Function (Ground Truth) -# ============================================================================= -def target_function(X: np.ndarray) -> np.ndarray: - """Complex target function: combination of sinusoids with noise. - - This simulates a real-world scenario where we have an expensive - simulation that we want to approximate with a surrogate model. - """ - return ( - np.sin(2 * np.pi * X) + - 0.5 * np.cos(4 * np.pi * X) + - 0.1 * np.random.randn(*X.shape) - ) - - -# ============================================================================= -# Data Persistence (Cross-Process Communication) -# ============================================================================= -def save_data(X_labeled, y_labeled, X_pool, y_pool, model=None, metadata=None): - """Save data and model state to file.""" - with open(DATA_FILE, "wb") as f: - pickle.dump({ - "X_labeled": X_labeled, - "y_labeled": y_labeled, - "X_pool": X_pool, - "y_pool": y_pool, - "model": model, - "metadata": metadata or {}, - }, f) - - -def load_data(): - """Load data and model state from file.""" - with open(DATA_FILE, "rb") as f: - return pickle.load(f) - - -# ============================================================================= -# ROSE Task Functions -# ============================================================================= -async def simulation(*args, n_initial: int = N_INITIAL_SAMPLES, - n_pool: int = N_POOL_SAMPLES) -> dict: - """Generate initial labeled data and unlabeled pool. - - In real applications, this would run expensive HPC simulations. - ROSE orchestrates when and where these simulations execute. - """ - np.random.seed(42) - - # Initial labeled set (expensive to obtain) - X_labeled = np.random.uniform(0, 1, (n_initial, 1)) - y_labeled = target_function(X_labeled) - - # Unlabeled pool (candidates for labeling) - X_pool = np.random.uniform(0, 1, (n_pool, 1)) - y_pool = target_function(X_pool) # Hidden labels (oracle) - - save_data(X_labeled, y_labeled, X_pool, y_pool) - - return { - "labeled_count": n_initial, - "unlabeled_count": n_pool, - "simulation_complete": True, - } - - -async def training(*args, length_scale: float = 0.5, - noise_level: float = 0.1) -> dict: - """Train a Gaussian Process surrogate model. - - ROSE ensures this runs after simulation completes. - """ - data = load_data() - - # Build and train GP model - kernel = RBF(length_scale=length_scale) + WhiteKernel(noise_level=noise_level) - model = GaussianProcessRegressor( - kernel=kernel, - n_restarts_optimizer=5, - normalize_y=True, - random_state=42 - ) - model.fit(data["X_labeled"], data["y_labeled"].ravel()) - - # Compute training metrics - y_train_pred = model.predict(data["X_labeled"]) - train_mse = mean_squared_error(data["y_labeled"], y_train_pred) - - # Extract learned hyperparameters - learned_params = model.kernel_.get_params() - - save_data( - data["X_labeled"], data["y_labeled"], - data["X_pool"], data["y_pool"], - model=model, - metadata={"train_mse": train_mse, "kernel_params": str(learned_params)} - ) - - return { - "train_mse": float(train_mse), - "length_scale": length_scale, - "noise_level": noise_level, - "n_training_samples": len(data["X_labeled"]), - } - - -async def active_learn(*args, n_select: int = N_SELECT_PER_ITERATION, - strategy: str = "uncertainty") -> dict: - """Select informative samples using active learning. - - ROSE manages the iteration loop and task dependencies. - """ - data = load_data() - model = data["model"] - X_pool = data["X_pool"] - y_pool = data["y_pool"] - X_labeled = data["X_labeled"] - y_labeled = data["y_labeled"] - - if len(X_pool) == 0: - return { - "labeled_count": len(X_labeled), - "unlabeled_count": 0, - "mean_uncertainty": 0.0, - "max_uncertainty": 0.0, - "samples_selected": 0, - "pool_exhausted": True, - } - - # Predict with uncertainty quantification - y_pred, std = model.predict(X_pool, return_std=True) - - # Selection strategy - if strategy == "uncertainty": - # Select most uncertain samples - scores = std - elif strategy == "random": - scores = np.random.rand(len(X_pool)) - else: - scores = std - - # Select top samples - n_select = min(n_select, len(X_pool)) - indices = np.argsort(scores)[-n_select:] - - # Compute selection statistics - selected_uncertainties = std[indices] - - # Move selected samples to labeled set - X_labeled = np.vstack([X_labeled, X_pool[indices]]) - y_labeled = np.vstack([y_labeled, y_pool[indices].reshape(-1, 1)]) - - # Remove from pool - X_pool = np.delete(X_pool, indices, axis=0) - y_pool = np.delete(y_pool, indices, axis=0) - - save_data(X_labeled, y_labeled, X_pool, y_pool, model=model) - - return { - "labeled_count": len(X_labeled), - "unlabeled_count": len(X_pool), - "mean_uncertainty": float(np.mean(std)), - "max_uncertainty": float(np.max(std)), - "min_uncertainty": float(np.min(std)), - "selected_mean_uncertainty": float(np.mean(selected_uncertainties)), - "samples_selected": n_select, - "selection_strategy": strategy, - "pool_exhausted": len(X_pool) == 0, - } - - -async def check_mse(*args) -> float: - """Evaluate model on held-out validation data. - - Returns the metric value that ROSE uses for stopping criterion. - """ - data = load_data() - model = data["model"] - - # Dense validation grid - X_val = np.linspace(0, 1, 100).reshape(-1, 1) - y_val = target_function(X_val) - y_pred = model.predict(X_val) - - return float(mean_squared_error(y_val, y_pred)) - - -# ============================================================================= -# MLflow Integration Layer -# ============================================================================= -class MLflowROSETracker: - """Integrates MLflow tracking with ROSE active learning workflow. - - This class demonstrates the complementary relationship: - - ROSE decides what to run and manages execution - - MLflow records what happened and stores artifacts - """ - - def __init__(self, experiment_name: str): - self.experiment_name = experiment_name - self.run = None - self.iteration_metrics = [] - - def start_experiment(self, config: dict): - """Initialize MLflow experiment and run.""" - mlflow.set_experiment(self.experiment_name) - - self.run = mlflow.start_run( - run_name=f"rose_al_{datetime.now().strftime('%Y%m%d_%H%M%S')}" - ) - - # Log configuration parameters - mlflow.log_params({ - "max_iterations": config.get("max_iterations", MAX_ITERATIONS), - "mse_threshold": config.get("mse_threshold", MSE_THRESHOLD), - "n_initial_samples": config.get("n_initial", N_INITIAL_SAMPLES), - "n_pool_samples": config.get("n_pool", N_POOL_SAMPLES), - "n_select_per_iteration": config.get("n_select", N_SELECT_PER_ITERATION), - "orchestrator": "ROSE", - "learner_type": "SequentialActiveLearner", - }) - - # Tag the run - mlflow.set_tags({ - "framework": "ROSE+MLflow", - "task_type": "active_learning", - "model_type": "GaussianProcessRegressor", - }) - - print(f"MLflow Run ID: {self.run.info.run_id}") - print(f"MLflow Experiment: {self.experiment_name}") - - def log_iteration(self, state): - """Log metrics from a ROSE iteration to MLflow. - - Args: - state: IterationState from ROSE learner - """ - iteration = state.iteration - - # Core metrics - metrics = { - "mse": state.metric_value, - "labeled_count": state.labeled_count, - "unlabeled_count": state.unlabeled_count, - } - - # Uncertainty metrics (if available) - if state.mean_uncertainty is not None: - metrics["mean_uncertainty"] = state.mean_uncertainty - if state.max_uncertainty is not None: - metrics["max_uncertainty"] = state.max_uncertainty - if state.min_uncertainty is not None: - metrics["min_uncertainty"] = state.min_uncertainty - - # Training metrics - if state.train_mse is not None: - metrics["train_mse"] = state.train_mse - - # Log all metrics with step - for name, value in metrics.items(): - if value is not None: - mlflow.log_metric(name, value, step=iteration) - - # Store for final summary - self.iteration_metrics.append({ - "iteration": iteration, - **metrics - }) - - def log_model(self, model, X_sample, y_sample): - """Log the trained model to MLflow model registry.""" - # Infer signature from sample data - signature = infer_signature(X_sample, model.predict(X_sample)) - - # Log model with signature - mlflow.sklearn.log_model( - model, - artifact_path="surrogate_model", - signature=signature, - registered_model_name=f"{self.experiment_name}_GP_Model", - ) - - def log_final_evaluation(self, model): - """Log comprehensive final evaluation metrics.""" - # Generate evaluation data - X_eval = np.linspace(0, 1, 200).reshape(-1, 1) - y_true = target_function(X_eval) - y_pred, y_std = model.predict(X_eval, return_std=True) - - # Compute various metrics - final_metrics = { - "final_mse": mean_squared_error(y_true, y_pred), - "final_mae": mean_absolute_error(y_true, y_pred), - "final_r2": r2_score(y_true, y_pred), - "final_mean_uncertainty": float(np.mean(y_std)), - "total_iterations": len(self.iteration_metrics), - } - - mlflow.log_metrics(final_metrics) - - # Log learning curve as artifact - self._log_learning_curve() - - return final_metrics - - def _log_learning_curve(self): - """Create and log learning curve visualization.""" - try: - import matplotlib - matplotlib.use('Agg') - import matplotlib.pyplot as plt - - iterations = [m["iteration"] for m in self.iteration_metrics] - mse_values = [m.get("mse", 0) for m in self.iteration_metrics] - labeled_counts = [m.get("labeled_count", 0) for m in self.iteration_metrics] - - fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4)) - - # MSE over iterations - ax1.plot(iterations, mse_values, 'b-o', linewidth=2, markersize=6) - ax1.axhline(y=MSE_THRESHOLD, color='r', linestyle='--', - label=f'Threshold ({MSE_THRESHOLD})') - ax1.set_xlabel('Iteration') - ax1.set_ylabel('MSE') - ax1.set_title('Active Learning: MSE vs Iteration') - ax1.legend() - ax1.grid(True, alpha=0.3) - ax1.set_yscale('log') - - # MSE vs labeled samples - ax2.plot(labeled_counts, mse_values, 'g-s', linewidth=2, markersize=6) - ax2.axhline(y=MSE_THRESHOLD, color='r', linestyle='--', - label=f'Threshold ({MSE_THRESHOLD})') - ax2.set_xlabel('Number of Labeled Samples') - ax2.set_ylabel('MSE') - ax2.set_title('Active Learning: MSE vs Sample Size') - ax2.legend() - ax2.grid(True, alpha=0.3) - ax2.set_yscale('log') - - plt.tight_layout() - - # Save and log - curve_path = Path(tempfile.gettempdir()) / "learning_curve.png" - plt.savefig(curve_path, dpi=150, bbox_inches='tight') - plt.close() - - mlflow.log_artifact(str(curve_path), artifact_path="plots") - curve_path.unlink() - - except ImportError: - print("matplotlib not available, skipping learning curve plot") - - def end_experiment(self, success: bool = True): - """Finalize MLflow run.""" - if self.run: - mlflow.set_tag("status", "success" if success else "failed") - mlflow.end_run() - print(f"MLflow run completed: {self.run.info.run_id}") - - -# ============================================================================= -# Main: ROSE + MLflow Integration -# ============================================================================= -async def main(): - """Run active learning with ROSE orchestration and MLflow tracking.""" - - print("=" * 60) - print("ROSE + MLflow Integration Example") - print("=" * 60) - print("\nROSE: Orchestrates the active learning workflow") - print("MLflow: Tracks experiments, metrics, and models") - print("=" * 60) - - # Initialize MLflow tracker - tracker = MLflowROSETracker(EXPERIMENT_NAME) - tracker.start_experiment({ - "max_iterations": MAX_ITERATIONS, - "mse_threshold": MSE_THRESHOLD, - "n_initial": N_INITIAL_SAMPLES, - "n_pool": N_POOL_SAMPLES, - "n_select": N_SELECT_PER_ITERATION, - }) - - try: - # Initialize ROSE workflow engine - engine = await ConcurrentExecutionBackend(ProcessPoolExecutor(max_workers=4)) - asyncflow = await WorkflowEngine.create(engine) - - # Create ROSE active learner - learner = SequentialActiveLearner(asyncflow) - - # Register task functions with ROSE - learner.simulation_task(as_executable=False)(simulation) - learner.training_task(as_executable=False)(training) - learner.active_learn_task(as_executable=False)(active_learn) - learner.as_stop_criterion( - metric_name=MEAN_SQUARED_ERROR_MSE, - threshold=MSE_THRESHOLD, - as_executable=False, - )(check_mse) - - print("\n[ROSE] Starting active learning loop...") - print("-" * 60) - - # Main active learning loop - ROSE orchestrates, MLflow records - final_state = None - async for state in learner.start(max_iter=MAX_ITERATIONS): - # ROSE yields control at each iteration with current state - print(f"\n[Iteration {state.iteration}]") - print(f" MSE: {state.metric_value:.6f} (threshold: {state.metric_threshold})") - print(f" Labeled: {state.labeled_count}, Pool: {state.unlabeled_count}") - - if state.mean_uncertainty: - print(f" Uncertainty - mean: {state.mean_uncertainty:.4f}, " - f"max: {state.max_uncertainty:.4f}") - - # MLflow logs the iteration metrics - tracker.log_iteration(state) - - # Dynamic configuration based on state (ROSE feature) - if state.mean_uncertainty and state.mean_uncertainty < 0.01: - # Increase batch size when uncertainty is low - learner.set_next_config( - LearnerConfig(active_learn=TaskConfig(kwargs={"n_select": 10})) - ) - print(" [Config] Low uncertainty detected, increasing batch size") - - # Check for pool exhaustion - if state.unlabeled_count < N_SELECT_PER_ITERATION: - print(" [Warning] Pool nearly exhausted") - - final_state = state - - # Custom early stopping (in addition to ROSE's criterion) - if state.metric_value and state.metric_value < MSE_THRESHOLD / 2: - print(f" [Early Stop] MSE well below threshold") - break - - print("\n" + "-" * 60) - print("[ROSE] Active learning completed") - - # Log final model to MLflow - if final_state: - data = load_data() - if data.get("model"): - print("\n[MLflow] Logging final model...") - tracker.log_model( - data["model"], - data["X_labeled"][:10], - data["y_labeled"][:10] - ) - - print("[MLflow] Computing final evaluation metrics...") - final_metrics = tracker.log_final_evaluation(data["model"]) - - print("\n" + "=" * 60) - print("Final Results") - print("=" * 60) - print(f" Total iterations: {final_metrics['total_iterations']}") - print(f" Final MSE: {final_metrics['final_mse']:.6f}") - print(f" Final MAE: {final_metrics['final_mae']:.6f}") - print(f" Final R2: {final_metrics['final_r2']:.4f}") - print(f" Final labeled samples: {final_state.labeled_count}") - - # Shutdown ROSE - await asyncflow.shutdown() - tracker.end_experiment(success=True) - - except Exception as e: - print(f"\n[Error] {e}") - tracker.end_experiment(success=False) - raise - - finally: - # Cleanup temporary data file - if DATA_FILE.exists(): - DATA_FILE.unlink() - - print("\n" + "=" * 60) - print("To view results, run:") - print(" mlflow ui --port 5000") - print("Then open http://192.168.0.172:5000") - print("=" * 60) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/integrations/tracking/README.md b/examples/integrations/tracking/README.md new file mode 100644 index 00000000..3a3a0463 --- /dev/null +++ b/examples/integrations/tracking/README.md @@ -0,0 +1,278 @@ +# ROSE Tracking Integrations + +ROSE has a pluggable tracking system built on the `TrackerBase` protocol. You attach a tracker +once with `learner.add_tracker(...)` — before calling `start()` — and the learner calls it +automatically at every lifecycle point. No tracking code inside your `async for` loop. + +``` +learner.add_tracker(MyTracker(...)) ← one line, before start() + +async for state in learner.start(...): + # your control logic only — tracking is automatic +``` + +Three trackers are available out of the box: + +| Tracker | File | Dependency | +|---------|------|------------| +| `HPC_FileTracker` | `run_me.py` | none (stdlib only) | +| `MLflowTracker` | `mlflow/run_me_tracker.py` | `pip install rose[mlflow]` | +| `ClearMLTracker` | `clearml/run_me.py` | `pip install rose[clearml]` | + +--- + +## Tracker lifecycle + +Every tracker receives three calls from the learner: + +| Method | When | What it receives | +|--------|------|-----------------| +| `on_start(manifest)` | Once, at `add_tracker()` | Full pipeline manifest: task names, criterion threshold/operator, learner type | +| `on_iteration(state)` | Once per iteration, before `yield` | Complete `IterationState` snapshot: metric, task outputs, `learner_id` | +| `on_stop(final_state, reason)` | Once, in `finally` | Last state + stop reason: `"criterion_met"` / `"max_iter_reached"` / `"stopped"` / `"error"` | + +Task outputs flow into `on_iteration` automatically: when a task returns a `dict`, ROSE +extracts each key-value pair into `IterationState.state`, making them available in every +`on_iteration` call. + +--- + +## 1 — HPC FileTracker (no external dependencies) + +**Science:** Branin-Hoo 2D GP surrogate — uncertainty sampling active learning on a canonical +benchmark with known global optima. Runs entirely with stdlib + numpy + scikit-learn. + +**Tracker:** Append-only JSON Lines file. Each call to `on_iteration` appends one line — +atomic at the POSIX level. If the HPC job is preempted, all completed iterations are already +on disk and can be inspected or resumed without rerunning the whole pipeline. + +```bash +python run_me.py + +# Inspect the output: +python -c " +import pandas +df = pandas.read_json('branin_run.jsonl', lines=True) +print(df[df.event == 'iteration'][['iteration', 'mse', 'n_labeled', 'log_marginal_likelihood']]) +" +``` + +**What gets logged automatically:** + +``` +{"event": "start", "learner_type": "SequentialActiveLearner", "criterion": {"metric": "mean_squared_error_mse", "threshold": 0.01}, ...} +{"event": "iteration", "iteration": 0, "mse": 0.014, "n_labeled": 25, "n_pool": 290, ...} +{"event": "iteration", "iteration": 1, "mse": 0.0012, "n_labeled": 35, "n_pool": 280, ...} +{"event": "stop", "reason": "criterion_met", "final_iteration": 1, "final_mse": 0.0012} +``` + +**Implement your own `HPC_FileTracker`:** + +```python +import json, time +from pathlib import Path +from rose import TrackerBase, PipelineManifest, IterationState + +class HPC_FileTracker(TrackerBase): + def __init__(self, path: str) -> None: + self._path = Path(path) + self._path.write_text("") # truncate on new run + self._t0 = 0.0 + + def on_start(self, manifest: PipelineManifest) -> None: + self._t0 = time.time() + self._write({"event": "start", "learner_type": manifest.learner_type}) + + def on_iteration(self, state: IterationState) -> None: + self._write({ + "event": "iteration", + "iteration": state.iteration, + "metric": state.metric_value, + **state.state, # all task outputs from dict return values + }) + + def on_stop(self, final_state, reason: str) -> None: + self._write({"event": "stop", "reason": reason, + "elapsed_s": round(time.time() - self._t0, 3)}) + + def _write(self, record: dict) -> None: + with self._path.open("a") as f: + f.write(json.dumps(record) + "\n") + +learner.add_tracker(HPC_FileTracker("run.jsonl")) +``` + +--- + +## 2 — MLflow Tracker + +**Science:** Rosenbrock 2D GP surrogate with an *adaptive kernel schedule* — the GP kernel +length-scale is tightened in three stages as the surrogate matures, mimicking curriculum-style +surrogate refinement. `set_next_config()` injects the new kernel parameters at iteration +boundaries and MLflow captures the config change automatically in the next `on_iteration()` call. + +**Tracker:** `MLflowTracker` from `rose.integrations.mlflow_tracker`. Logs the pipeline +manifest as run parameters on start, per-iteration metrics as MLflow scalars, and stop reason +as a tag on stop. + +```bash +pip install rose[mlflow] scikit-learn numpy + +python mlflow/run_me_tracker.py + +# View results: +mlflow ui --port 5000 +# Open http://localhost:5000 → experiment "ROSE-Rosenbrock-Surrogate" +``` + +**What gets logged automatically:** + +``` +Params (on_start): + learner_type = "SequentialActiveLearner" + criterion/metric_name = "mean_squared_error_mse" + criterion/threshold = 0.002 + task.simulation.as_executable = False + task.training.as_executable = False + task.training.kernel = "RBF+WhiteKernel" ← from log_params in decorator + task.training.kernel_schedule = "adaptive" ← from log_params in decorator + ... + +Metrics (on_iteration, per step): + MEAN_SQUARED_ERROR_MSE ← stop criterion value + n_labeled ← from training task dict return + n_pool + train_mse ← from training task dict return + log_marginal_likelihood ← from training task dict return + length_scale_used + +Tags (on_stop): + stop_reason = "criterion_met" + final_iteration = "4" +``` + +**Wire it:** + +```python +from rose.integrations.mlflow_tracker import MLflowTracker + +# Register tasks first, then attach the tracker +@learner.training_task(as_executable=False, log_params={"kernel": "rbf"}) +async def train(*args, **kwargs): ... + +learner.add_tracker( # on_start(manifest) fires here — manifest is complete + MLflowTracker( + experiment_name="my-surrogate", + run_name="gp-v1", + ) +) +async for state in learner.start(max_iter=30): + ... # no mlflow calls here — all automatic +``` + +--- + +## 3 — ClearML Tracker + +**Science:** 5D materials formation energy prediction using two parallel active learners +(`"ensemble-A"`, `"ensemble-B"`, different random seeds). Both run concurrently — each +trains its own GP and selects its own labeled points. ClearML shows both learners' +convergence curves side by side, making seed sensitivity immediately visible. + +**Tracker:** `ClearMLTracker` from `rose.integrations.clearml_tracker`. Each yielded +`IterationState` carries `state.learner_id` (0 or 1), so both learners' scalar curves +appear in the same ClearML task and can be overlaid for direct convergence comparison. + +```bash +pip install rose[clearml] scikit-learn numpy + +python clearml/run_me.py + +# Open ClearML web UI → project "ROSE-Materials-UQ" +# Scalars tab: overlay mse curves for ensemble-A vs ensemble-B +``` + +**What gets logged automatically:** + +``` +Hyperparameters (on_start): + learner_type = "ParallelActiveLearner" + criterion/metric_name = "mean_squared_error_mse" + criterion/threshold = 0.01 + ... + +Scalars (on_iteration, per learner per step): + title=MEAN_SQUARED_ERROR_MSE series=0 ← ensemble-A + title=MEAN_SQUARED_ERROR_MSE series=1 ← ensemble-B + title=n_labeled series=0/1 + title=train_mse series=0/1 + +Task tags (on_stop): + stop:criterion_met or stop:max_iter_reached + final_iter:N +``` + +**Wire it:** + +```python +from rose.integrations.clearml_tracker import ClearMLTracker + +learner.add_tracker( + ClearMLTracker( + project_name="my-project", + task_name="ensemble-run-01", + ) +) +async for state in learner.start(learner_names=["A", "B"], max_iter=15): + print(f"[{state.learner_id}] iter={state.iteration} mse={state.metric_value:.4f}") +``` + +--- + +## Using multiple trackers simultaneously + +Trackers are independent observers — attach as many as you want: + +```python +from rose.integrations.mlflow_tracker import MLflowTracker +from rose.integrations.clearml_tracker import ClearMLTracker + +learner.add_tracker(HPC_FileTracker("run.jsonl")) # always-on safety net +learner.add_tracker(MLflowTracker(experiment_name="x")) # experiment comparison +learner.add_tracker(ClearMLTracker(project_name="x", task_name="y")) # team dashboard + +async for state in learner.start(max_iter=20): + ... # all three trackers fire automatically at each iteration +``` + +If one tracker raises an exception, the others are unaffected and the learner continues. + +--- + +## Writing a custom tracker + +Any class with the three methods is a valid tracker — no import from ROSE required: + +```python +class SlackTracker: + """Post a Slack message when the run finishes.""" + + def __init__(self, webhook_url: str) -> None: + self._url = webhook_url + self._iters = 0 + + def on_start(self, manifest): pass + def on_iteration(self, state): self._iters += 1 + + def on_stop(self, final_state, reason: str) -> None: + import urllib.request, json + msg = (f"ROSE run finished after {self._iters} iterations. " + f"Reason: {reason}. " + f"Final metric: {final_state.metric_value if final_state else 'N/A'}") + data = json.dumps({"text": msg}).encode() + urllib.request.urlopen(urllib.request.Request( + self._url, data=data, headers={"Content-Type": "application/json"} + )) + +learner.add_tracker(SlackTracker("https://hooks.slack.com/...")) +``` diff --git a/examples/integrations/tracking/basic.py b/examples/integrations/tracking/basic.py new file mode 100644 index 00000000..08e0628d --- /dev/null +++ b/examples/integrations/tracking/basic.py @@ -0,0 +1,325 @@ +"""Native ROSE FileTracker — HPC-safe append-only JSON Lines logging. + +Science +------- +Gaussian Process surrogate for the Branin-Hoo benchmark function, a canonical +test problem in surrogate-based optimisation with two input variables and a +known ground truth. The learner uses GP predictive variance (uncertainty +sampling) to select the most informative points from the candidate pool. + +Stop criterion : MSE < 0.01 on a fixed 400-point validation grid. + +Tracker +------- +``HPC_FileTracker`` implements ``TrackerBase`` using append-only JSON Lines +output — one record per iteration. JSON Lines is the correct format for HPC: + + - Append-only: each write is atomic; no record is ever overwritten + - Survives job preemption: all completed iterations are already on disk + - Human-readable and importable with ``pandas.read_json(..., lines=True)`` + +Task outputs (returned as dicts) are captured automatically via ``on_iteration``. + +Requirements +------------ + pip install numpy scikit-learn + +Usage +----- + python run_me.py + + # Inspect results: + python -c "import pandas; print(pandas.read_json('branin_run.jsonl', lines=True))" +""" + +import asyncio +import json +import pickle +import tempfile +import time +from concurrent.futures import ProcessPoolExecutor +from pathlib import Path + +import numpy as np +from radical.asyncflow import WorkflowEngine +from rhapsody.backends import ConcurrentExecutionBackend +from sklearn.gaussian_process import GaussianProcessRegressor +from sklearn.gaussian_process.kernels import RBF, WhiteKernel +from sklearn.metrics import mean_squared_error + +from rose import IterationState, PipelineManifest, TrackerBase +from rose.al.active_learner import SequentialActiveLearner +from rose.metrics import MEAN_SQUARED_ERROR_MSE + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +MAX_ITERATIONS = 20 +MSE_THRESHOLD = 0.01 +N_INITIAL = 15 +N_POOL = 300 +N_SELECT = 10 +DATA_FILE = Path(tempfile.gettempdir()) / "branin_al_data.pkl" +JSONL_FILE = Path("branin_run.jsonl") + + +# --------------------------------------------------------------------------- +# Ground truth: Branin-Hoo function (rescaled to [0,1]²) +# --------------------------------------------------------------------------- +def branin(X: np.ndarray) -> np.ndarray: + """Branin-Hoo benchmark: f(x1,x2) has 3 global minima at f≈0.398.""" + x1 = X[:, 0] * 15.0 - 5.0 # rescale [0,1] → [-5, 10] + x2 = X[:, 1] * 15.0 # rescale [0,1] → [0, 15] + a, b, c = 1.0, 5.1 / (4 * np.pi**2), 5.0 / np.pi + r, s, t = 6.0, 10.0, 1.0 / (8.0 * np.pi) + y = a * (x2 - b * x1**2 + c * x1 - r) ** 2 + s * (1 - t) * np.cos(x1) + s + # Normalise to [0,1] range for MSE interpretability + return (y / 300.0).reshape(-1, 1) + + +# --------------------------------------------------------------------------- +# Shared state helpers +# --------------------------------------------------------------------------- +def save_state(data: dict) -> None: + with open(DATA_FILE, "wb") as f: + pickle.dump(data, f) + + +def load_state() -> dict: + with open(DATA_FILE, "rb") as f: + return pickle.load(f) + + +# --------------------------------------------------------------------------- +# HPC_FileTracker — native TrackerBase, zero external dependencies +# --------------------------------------------------------------------------- +class HPC_FileTracker(TrackerBase): + """Append-only JSON Lines tracker designed for HPC job preemption safety. + + Each call to ``_write`` is a single ``f.write()`` — atomic at the OS + level on POSIX filesystems. If the job is preempted mid-run, all + iterations written before the preemption are intact on disk. + + Post-processing:: + + import pandas + df = pandas.read_json("branin_run.jsonl", lines=True) + iterations = df[df.event == "iteration"] + iterations.plot(x="iteration", y="mse", logy=True) + """ + + def __init__(self, path: str | Path) -> None: + self._path = Path(path) + self._path.parent.mkdir(parents=True, exist_ok=True) + # Truncate on new run — remove if you want to resume and append + self._path.write_text("") + self._t0: float = 0.0 + + # ── Lifecycle ────────────────────────────────────────────────────────── + + def on_start(self, manifest: PipelineManifest) -> None: + self._t0 = time.time() + self._write( + { + "event": "start", + "ts": self._t0, + "learner_type": manifest.learner_type, + "tasks": list(manifest.tasks.keys()), + "criterion": { + "metric": manifest.criterion.metric_name, + "threshold": manifest.criterion.threshold, + "operator": manifest.criterion.operator, + } + if manifest.criterion + else None, + } + ) + print(f"[Tracker] Logging to {self._path.resolve()}") + + def on_iteration(self, state: IterationState) -> None: + self._write( + { + "event": "iteration", + "iteration": state.iteration, + "elapsed_s": round(time.time() - self._t0, 3), + # Core metric (MSE from stop criterion) + "mse": state.metric_value, + "should_stop": state.should_stop, + # Task outputs auto-extracted from dict return values + "n_labeled": state.get("n_labeled"), + "n_pool": state.get("n_pool"), + "mean_std": state.get("mean_std"), + "max_std": state.get("max_std"), + "train_mse": state.get("train_mse"), + "log_marginal_likelihood": state.get("log_marginal_likelihood"), + } + ) + + def on_stop(self, final_state: IterationState | None, reason: str) -> None: + self._write( + { + "event": "stop", + "reason": reason, + "elapsed_s": round(time.time() - self._t0, 3), + "final_iteration": final_state.iteration if final_state else None, + "final_mse": final_state.metric_value if final_state else None, + } + ) + print(f"[Tracker] Run complete — reason={reason!r}, file={self._path.resolve()}") + + # ── Internal ─────────────────────────────────────────────────────────── + + def _write(self, record: dict) -> None: + with self._path.open("a") as f: + f.write(json.dumps(record) + "\n") + + +# --------------------------------------------------------------------------- +# ROSE task functions +# --------------------------------------------------------------------------- +async def simulation(*args, n_initial: int = N_INITIAL, n_pool: int = N_POOL) -> dict: + """Sample initial labeled set and candidate pool from Branin input space. + + Idempotent: if the data file already exists (main-loop calls after the + pre-loop initialisation), this is a no-op that returns current statistics + without resetting the accumulated labeled set. + """ + if DATA_FILE.exists(): + data = load_state() + return {"n_labeled": len(data["X_labeled"]), "n_pool": len(data["X_pool"])} + rng = np.random.default_rng(42) + X_labeled = rng.uniform(0, 1, (n_initial, 2)) + y_labeled = branin(X_labeled) + X_pool = rng.uniform(0, 1, (n_pool, 2)) + y_pool = branin(X_pool) + save_state( + { + "X_labeled": X_labeled, + "y_labeled": y_labeled, + "X_pool": X_pool, + "y_pool": y_pool, + "model": None, + } + ) + return {"n_labeled": n_initial, "n_pool": n_pool} + + +async def training(*args, length_scale: float = 0.3, noise_level: float = 0.01) -> dict: + """Fit an ARD GP surrogate on the current labeled set.""" + data = load_state() + kernel = RBF(length_scale=[length_scale] * 2) + WhiteKernel(noise_level=noise_level) + gp = GaussianProcessRegressor( + kernel=kernel, n_restarts_optimizer=3, normalize_y=True, random_state=0 + ) + gp.fit(data["X_labeled"], data["y_labeled"].ravel()) + lml = float(gp.log_marginal_likelihood_value_) + y_pred_train = gp.predict(data["X_labeled"]) + train_mse = float(mean_squared_error(data["y_labeled"], y_pred_train)) + save_state({**data, "model": gp}) + return { + "train_mse": train_mse, + "log_marginal_likelihood": lml, + "n_labeled": len(data["X_labeled"]), + } + + +async def active_learn(*args, n_select: int = N_SELECT) -> dict: + """Uncertainty sampling: add the n_select most uncertain pool points to the labeled set.""" + data = load_state() + gp = data["model"] + X_pool, y_pool = data["X_pool"], data["y_pool"] + X_labeled, y_labeled = data["X_labeled"], data["y_labeled"] + + if len(X_pool) == 0: + return {"n_labeled": len(X_labeled), "n_pool": 0, "mean_std": 0.0, "max_std": 0.0} + + _, std = gp.predict(X_pool, return_std=True) + n_sel = min(n_select, len(X_pool)) + idx = np.argsort(std)[-n_sel:] + + X_labeled = np.vstack([X_labeled, X_pool[idx]]) + y_labeled = np.vstack([y_labeled, y_pool[idx]]) + X_pool = np.delete(X_pool, idx, axis=0) + y_pool = np.delete(y_pool, idx, axis=0) + save_state( + {**data, "X_labeled": X_labeled, "y_labeled": y_labeled, "X_pool": X_pool, "y_pool": y_pool} + ) + return { + "n_labeled": len(X_labeled), + "n_pool": len(X_pool), + "mean_std": float(std.mean()), + "max_std": float(std.max()), + } + + +async def check_mse(*args) -> float: + """Evaluate surrogate MSE on a fixed 20×20 validation grid over [0,1]².""" + data = load_state() + gp = data["model"] + grid = np.linspace(0, 1, 20) + xx, yy = np.meshgrid(grid, grid) + X_val = np.column_stack([xx.ravel(), yy.ravel()]) + y_val = branin(X_val) + y_pred = gp.predict(X_val) + return float(mean_squared_error(y_val, y_pred)) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +async def main() -> None: + engine = await ConcurrentExecutionBackend(ProcessPoolExecutor()) + asyncflow = await WorkflowEngine.create(engine) + learner = SequentialActiveLearner(asyncflow) + + # ── Attach tracker — logs everything automatically from here on ────── + learner.add_tracker(HPC_FileTracker(JSONL_FILE)) + + @learner.simulation_task(as_executable=False) + async def sim(*args, **kwargs): + return await simulation(*args, **kwargs) + + @learner.training_task(as_executable=False) + async def train(*args, **kwargs): + return await training(*args, **kwargs) + + @learner.active_learn_task(as_executable=False) + async def active(*args, **kwargs): + return await active_learn(*args, **kwargs) + + @learner.as_stop_criterion( + metric_name=MEAN_SQUARED_ERROR_MSE, + threshold=MSE_THRESHOLD, + operator="<", + as_executable=False, + ) + async def criterion(*args, **kwargs): + return await check_mse(*args, **kwargs) + + print("=" * 55) + print("ROSE Native FileTracker — Branin-Hoo GP Surrogate") + print("=" * 55) + + async for state in learner.start(max_iter=MAX_ITERATIONS): + print( + f"[iter {state.iteration:3d}] " + f"MSE={state.metric_value:.5f} " + f"labeled={state.get('n_labeled'):3d} " + f"pool={state.get('n_pool'):3d} " + f"LML={state.get('log_marginal_likelihood'):+.1f}" + ) + + await learner.shutdown() + DATA_FILE.unlink(missing_ok=True) + + print() + print("Replay run with:") + print( + f'python -c "import pandas; ' + f"print(pandas.read_json('{JSONL_FILE}', lines=True)" + f"[['iteration','mse','n_labeled']].dropna())\"" + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/integrations/tracking/clearml/run_me.py b/examples/integrations/tracking/clearml/run_me.py new file mode 100644 index 00000000..e0ce5a58 --- /dev/null +++ b/examples/integrations/tracking/clearml/run_me.py @@ -0,0 +1,288 @@ +"""ClearML Tracker — parallel ensemble active learning for material property prediction. + +Science +------- +Ensemble-based active learning for a materials science regression task: +predicting the formation energy of hypothetical crystal structures from +a set of structural descriptors (5-D Coulomb matrix eigenvalues). Two +parallel learners (``"ensemble-A"`` and ``"ensemble-B"``) train on different +random seeds, building diverse GP models. Their per-iteration metrics are +logged as separate ClearML scalar series inside the same task, enabling direct +convergence comparison in the ClearML UI. + +Stop criterion: MSE < 0.01 on a held-out 200-point test set. + +Scientific value: + - Ensemble diversity (different seeds) reduces systematic error + - ClearML overlay plot shows whether both seeds converge at the same rate + - Parallel execution: both learners run concurrently, not sequentially + +ClearML captures +---------------- + ``on_start`` → hyperparams: learner_type, criterion threshold/operator, + task names, as_executable flag + ``on_iteration`` → per-learner scalars: mse, train_mse, n_labeled, n_pool + (each as a separate ClearML series per learner_id) + ``on_stop`` → task tag "stop:criterion_met" or "stop:max_iter_reached" + +Requirements +------------ + pip install rose[clearml] scikit-learn numpy + +Usage +----- + python run_me.py + + # View results: + # Open ClearML web UI → project "ROSE-Materials-UQ" + # Scalars tab: overlay mse curves for ensemble-A vs ensemble-B +""" + +import asyncio +import pickle +import tempfile +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import numpy as np +from radical.asyncflow import WorkflowEngine +from rhapsody.backends import ConcurrentExecutionBackend +from sklearn.gaussian_process import GaussianProcessRegressor +from sklearn.gaussian_process.kernels import RBF, WhiteKernel +from sklearn.metrics import mean_squared_error + +from rose.al.active_learner import ParallelActiveLearner +from rose.integrations.clearml_tracker import ClearMLTracker +from rose.learner import LearnerConfig, TaskConfig +from rose.metrics import MEAN_SQUARED_ERROR_MSE + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +MAX_ITERATIONS = 20 +MSE_THRESHOLD = 0.01 +N_INITIAL = 20 +N_POOL = 400 +N_SELECT_AL = 12 +LEARNER_NAMES = ["ensemble-A", "ensemble-B"] +SEEDS = {"ensemble-A": 11, "ensemble-B": 37} + +DATA_FILE = Path(tempfile.gettempdir()) / "materials_al_{name}.pkl" + + +# --------------------------------------------------------------------------- +# Synthetic materials dataset: 5-D Coulomb matrix feature space +# Ground truth: formation energy proxy f(x) = sum_i sin(2πxᵢ) · xᵢ +# --------------------------------------------------------------------------- +def formation_energy(X: np.ndarray) -> np.ndarray: + """Proxy formation energy function — non-linear 5-D benchmark.""" + val = np.sum(np.sin(2 * np.pi * X) * X, axis=1) + return ((val - val.min()) / (val.max() - val.min() + 1e-9)).reshape(-1, 1) + + +def save_state(name: str, data: dict) -> None: + path = Path(str(DATA_FILE).format(name=name)) + with open(path, "wb") as f: + pickle.dump(data, f) + + +def load_state(name: str) -> dict: + path = Path(str(DATA_FILE).format(name=name)) + with open(path, "rb") as f: + return pickle.load(f) + + +# --------------------------------------------------------------------------- +# ROSE task functions — each receives --learner_name to isolate per-seed state +# --------------------------------------------------------------------------- +async def simulation(*args, **kwargs) -> dict: + """Generate initial labeled structures and unlabeled candidate pool. + + Idempotent: main-loop calls after pre-loop initialisation return + current statistics without resetting the accumulated labeled set. + """ + name = kwargs.get("--learner_name", "default") + path = Path(str(DATA_FILE).format(name=name)) + if path.exists(): + data = load_state(name) + return {"n_labeled": len(data["X_labeled"]), "n_pool": len(data["X_pool"])} + seed = SEEDS.get(name, 0) + rng = np.random.default_rng(seed) + X_labeled = rng.uniform(0, 1, (N_INITIAL, 5)) + y_labeled = formation_energy(X_labeled) + X_pool = rng.uniform(0, 1, (N_POOL, 5)) + y_pool = formation_energy(X_pool) + save_state( + name, + { + "X_labeled": X_labeled, + "y_labeled": y_labeled, + "X_pool": X_pool, + "y_pool": y_pool, + "model": None, + "seed": seed, + }, + ) + return {"n_labeled": N_INITIAL, "n_pool": N_POOL} + + +async def training(*args, **kwargs) -> dict: + """Fit GP model on current labeled set (seed-specific random init).""" + name = kwargs.get("--learner_name", "default") + data = load_state(name) + kernel = RBF(length_scale=[0.3] * 5) + WhiteKernel(noise_level=0.01) + gp = GaussianProcessRegressor( + kernel=kernel, + n_restarts_optimizer=0, + normalize_y=True, + random_state=data["seed"], + ) + gp.fit(data["X_labeled"], data["y_labeled"].ravel()) + lml = float(gp.log_marginal_likelihood_value_) + train_mse = float(mean_squared_error(data["y_labeled"], gp.predict(data["X_labeled"]))) + save_state(name, {**data, "model": gp}) + return { + "train_mse": train_mse, + "log_marginal_likelihood": lml, + "n_labeled": len(data["X_labeled"]), + } + + +async def active_learn(*args, **kwargs) -> dict: + """Select most uncertain pool candidates and add them to the labeled set.""" + name = kwargs.get("--learner_name", "default") + data = load_state(name) + gp, X_pool, y_pool = data["model"], data["X_pool"], data["y_pool"] + X_labeled, y_labeled = data["X_labeled"], data["y_labeled"] + + if len(X_pool) == 0: + return {"n_labeled": len(X_labeled), "n_pool": 0} + + _, std = gp.predict(X_pool, return_std=True) + n_sel = min(N_SELECT_AL, len(X_pool)) + idx = np.argsort(std)[-n_sel:] + X_labeled = np.vstack([X_labeled, X_pool[idx]]) + y_labeled = np.vstack([y_labeled, y_pool[idx]]) + X_pool = np.delete(X_pool, idx, axis=0) + y_pool = np.delete(y_pool, idx, axis=0) + save_state( + name, + { + **data, + "X_labeled": X_labeled, + "y_labeled": y_labeled, + "X_pool": X_pool, + "y_pool": y_pool, + }, + ) + return { + "n_labeled": len(X_labeled), + "n_pool": len(X_pool), + "mean_std": float(std.mean()), + } + + +async def check_accuracy(*args, **kwargs) -> float: + """MSE on a fixed 200-point held-out test set — stop criterion.""" + name = kwargs.get("--learner_name", "default") + data = load_state(name) + rng = np.random.default_rng(999) + X_test = rng.uniform(0, 1, (200, 5)) + y_test = formation_energy(X_test) + y_pred = data["model"].predict(X_test) + return float(mean_squared_error(y_test, y_pred)) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +async def main() -> None: + # Always start fresh — clear stale per-learner data files + for name in LEARNER_NAMES: + Path(str(DATA_FILE).format(name=name)).unlink(missing_ok=True) + + engine = await ConcurrentExecutionBackend(ThreadPoolExecutor()) + asyncflow = await WorkflowEngine.create(engine) + learner = ParallelActiveLearner(asyncflow) + + # Register all tasks first — add_tracker() fires on_start(manifest) immediately, + # so tasks must be registered before the tracker is attached. + @learner.simulation_task(as_executable=False) + async def sim(*args, **kwargs): + return await simulation(*args, **kwargs) + + @learner.training_task(as_executable=False) + async def train(*args, **kwargs): + return await training(*args, **kwargs) + + @learner.active_learn_task(as_executable=False) + async def active(*args, **kwargs): + return await active_learn(*args, **kwargs) + + @learner.as_stop_criterion( + metric_name=MEAN_SQUARED_ERROR_MSE, + threshold=MSE_THRESHOLD, + operator="<", + as_executable=False, + ) + async def criterion(*args, **kwargs): + return await check_accuracy(*args, **kwargs) + + # ── Attach tracker after all tasks are registered ───────────────────── + # on_start(manifest) fires here — the manifest is now complete. + learner.add_tracker( + ClearMLTracker( + project_name="ROSE-Materials-UQ", + task_name="parallel-ensemble-gp", + learner_names=LEARNER_NAMES, + ) + ) + + # Per-learner configs: inject --learner_name so each task accesses its own state file + learner_configs = [ + LearnerConfig( + simulation=TaskConfig(kwargs={"--learner_name": name}), + training=TaskConfig(kwargs={"--learner_name": name}), + active_learn=TaskConfig(kwargs={"--learner_name": name}), + criterion=TaskConfig(kwargs={"--learner_name": name}), + ) + for name in LEARNER_NAMES + ] + + print("=" * 60) + print("ROSE + ClearMLTracker — Parallel Ensemble Active Learning") + print("Learners:", LEARNER_NAMES) + print("=" * 60) + + async for state in learner.start( + parallel_learners=len(LEARNER_NAMES), + max_iter=MAX_ITERATIONS, + learner_configs=learner_configs, + ): + label = ( + LEARNER_NAMES[state.learner_id] + if isinstance(state.learner_id, int) + else state.learner_id + ) + print( + f"[{label:12s} iter {state.iteration:3d}] " + f"MSE={state.metric_value:.5f} " + f"labeled={state.get('n_labeled')} " + f"pool={state.get('n_pool')}" + ) + + await learner.shutdown() + + # Clean up per-learner data files + for name in LEARNER_NAMES: + Path(str(DATA_FILE).format(name=name)).unlink(missing_ok=True) + + print() + print("View results:") + print(" Open ClearML web UI → project 'ROSE-Materials-UQ'") + print(" → Scalars tab: compare mse curves for ensemble-A vs ensemble-B") + print(" → Tags: stop:criterion_met or stop:max_iter_reached per learner") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/integrations/tracking/mlflow/run_me_tracker.py b/examples/integrations/tracking/mlflow/run_me_tracker.py new file mode 100644 index 00000000..30429c0b --- /dev/null +++ b/examples/integrations/tracking/mlflow/run_me_tracker.py @@ -0,0 +1,288 @@ +"""MLflow OnP Tracker — adaptive GP surrogate with kernel schedule logging. + +Science +------- +Gaussian Process surrogate for the 2-D Rosenbrock function — a non-convex +benchmark commonly used to validate surrogate optimisation algorithms. The +key scientific feature here is an *adaptive kernel schedule*: the GP kernel +length-scale is tightened over iterations as the surrogate becomes more +accurate, mimicking the practice of curriculum-style surrogate refinement. + + Iteration 0–9 : RBF(length_scale=0.5) — broad, explores the landscape + Iteration 10–19 : RBF(length_scale=0.2) — focused, refines local detail + Iteration 20+ : RBF(length_scale=0.08) — tight, captures fine structure + +Stop criterion : MSE < 1e-6 on a fixed 400-point validation grid. + +MLflow captures +--------------- + ``on_start`` → run params: learner_type, criterion threshold/operator, + task names, as_executable flag, and any keys declared in + ``log_params`` at decoration time (e.g. kernel, num_gpus) + ``on_iteration`` → mse (step metric), n_labeled, n_pool, mean_std, + train_mse, log_marginal_likelihood per iteration + ``on_stop`` → tag stop_reason, final_iteration + +Difference from the manual ``mlflow_rose.py`` example +------------------------------------------------------ +The old example wires MLflow manually inside the ``async for`` loop. +This example uses ``learner.add_tracker(MLflowTracker(...))`` — a single +line before ``start()`` — and the learner calls the tracker automatically. +No MLflow code appears inside the loop. + +Requirements +------------ + pip install rose[mlflow] scikit-learn numpy + +Usage +----- + python run_me_tracker.py + + # View results: + mlflow ui --port 5000 + # Open http://localhost:5000 → experiment "ROSE-Rosenbrock-Surrogate" +""" + +import asyncio +import pickle +import tempfile +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import numpy as np +from radical.asyncflow import WorkflowEngine +from rhapsody.backends import ConcurrentExecutionBackend +from sklearn.gaussian_process import GaussianProcessRegressor +from sklearn.gaussian_process.kernels import RBF, WhiteKernel +from sklearn.metrics import mean_squared_error + +from rose.al.active_learner import SequentialActiveLearner +from rose.integrations.mlflow_tracker import MLflowTracker +from rose.learner import LearnerConfig, TaskConfig +from rose.metrics import MEAN_SQUARED_ERROR_MSE + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +MAX_ITERATIONS = 35 +MSE_THRESHOLD = 1e-6 +N_INITIAL = 20 +N_POOL = 800 +N_SELECT = 15 +DATA_FILE = Path(tempfile.gettempdir()) / "rosenbrock_al_data.pkl" + +# Kernel schedule: iteration threshold → (length_scale, noise_level) +KERNEL_SCHEDULE: dict[int, tuple[float, float]] = { + 0: (0.50, 0.05), # broad kernel — exploration phase + 10: (0.20, 0.01), # medium kernel — transition phase + 20: (0.08, 0.002), # tight kernel — refinement phase +} + + +# --------------------------------------------------------------------------- +# Ground truth: 2-D Rosenbrock f(x,y) = (1-x)² + 100(y-x²)² +# --------------------------------------------------------------------------- +def rosenbrock(X: np.ndarray) -> np.ndarray: + """Rosenbrock function rescaled to [0,1]² input, output in [0,1].""" + x = X[:, 0] * 4.0 - 2.0 # [0,1] → [-2, 2] + y = X[:, 1] * 4.0 - 2.0 # [0,1] → [-2, 2] + z = (1 - x) ** 2 + 100 * (y - x**2) ** 2 + return (z / 3600.0).reshape(-1, 1) # normalise to ≈[0,1] + + +# --------------------------------------------------------------------------- +# Shared state helpers +# --------------------------------------------------------------------------- +def save_state(data: dict) -> None: + with open(DATA_FILE, "wb") as f: + pickle.dump(data, f) + + +def load_state() -> dict: + with open(DATA_FILE, "rb") as f: + return pickle.load(f) + + +# --------------------------------------------------------------------------- +# ROSE task functions +# --------------------------------------------------------------------------- +async def simulation(*args, n_initial: int = N_INITIAL, n_pool: int = N_POOL) -> dict: + """Sample initial labeled set and unlabeled candidate pool. + + Idempotent: main-loop calls after the pre-loop initialisation return + current statistics without resetting the accumulated labeled set. + """ + if DATA_FILE.exists(): + data = load_state() + return {"n_labeled": len(data["X_labeled"]), "n_pool": len(data["X_pool"])} + rng = np.random.default_rng(7) + X_labeled = rng.uniform(0, 1, (n_initial, 2)) + y_labeled = rosenbrock(X_labeled) + X_pool = rng.uniform(0, 1, (n_pool, 2)) + y_pool = rosenbrock(X_pool) + save_state( + { + "X_labeled": X_labeled, + "y_labeled": y_labeled, + "X_pool": X_pool, + "y_pool": y_pool, + "model": None, + } + ) + return {"n_labeled": n_initial, "n_pool": n_pool} + + +async def training( + *args, + length_scale: float = 0.5, + noise_level: float = 0.05, +) -> dict: + """Fit GP surrogate; kernel hyperparameters come from the iteration config.""" + data = load_state() + kernel = RBF(length_scale=[length_scale] * 2) + WhiteKernel(noise_level=noise_level) + gp = GaussianProcessRegressor( + kernel=kernel, n_restarts_optimizer=3, normalize_y=True, random_state=0 + ) + gp.fit(data["X_labeled"], data["y_labeled"].ravel()) + lml = float(gp.log_marginal_likelihood_value_) + train_mse = float(mean_squared_error(data["y_labeled"], gp.predict(data["X_labeled"]))) + save_state({**data, "model": gp}) + return { + "train_mse": train_mse, + "log_marginal_likelihood": lml, + "n_labeled": len(data["X_labeled"]), + "length_scale_used": length_scale, + } + + +async def active_learn(*args, n_select: int = N_SELECT) -> dict: + """Uncertainty sampling: label the most uncertain pool candidates.""" + data = load_state() + gp, X_pool, y_pool = data["model"], data["X_pool"], data["y_pool"] + X_labeled, y_labeled = data["X_labeled"], data["y_labeled"] + + if len(X_pool) == 0: + return {"n_labeled": len(X_labeled), "n_pool": 0, "mean_std": 0.0, "max_std": 0.0} + + _, std = gp.predict(X_pool, return_std=True) + n_sel = min(n_select, len(X_pool)) + idx = np.argsort(std)[-n_sel:] + + X_labeled = np.vstack([X_labeled, X_pool[idx]]) + y_labeled = np.vstack([y_labeled, y_pool[idx]]) + X_pool = np.delete(X_pool, idx, axis=0) + y_pool = np.delete(y_pool, idx, axis=0) + save_state( + {**data, "X_labeled": X_labeled, "y_labeled": y_labeled, "X_pool": X_pool, "y_pool": y_pool} + ) + return { + "n_labeled": len(X_labeled), + "n_pool": len(X_pool), + "mean_std": float(std.mean()), + "max_std": float(std.max()), + } + + +async def check_mse(*args) -> float: + """MSE on a fixed 20×20 validation grid — the stop criterion metric.""" + data = load_state() + grid = np.linspace(0, 1, 20) + xx, yy = np.meshgrid(grid, grid) + X_val = np.column_stack([xx.ravel(), yy.ravel()]) + y_pred = data["model"].predict(X_val) + return float(mean_squared_error(rosenbrock(X_val), y_pred)) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +async def main() -> None: + DATA_FILE.unlink(missing_ok=True) # always start fresh + + engine = await ConcurrentExecutionBackend(ThreadPoolExecutor()) + asyncflow = await WorkflowEngine.create(engine) + learner = SequentialActiveLearner(asyncflow) + + # Register all tasks first — add_tracker() fires on_start(manifest) immediately, + # so tasks must be registered before the tracker is attached. + @learner.simulation_task(as_executable=False) + async def sim(*args, **kwargs): + return await simulation(*args, **kwargs) + + @learner.training_task( + as_executable=False, + log_params={"kernel": "RBF+WhiteKernel", "kernel_schedule": "adaptive"}, + ) + async def train(*args, **kwargs): + return await training(*args, **kwargs) + + @learner.active_learn_task(as_executable=False) + async def active(*args, **kwargs): + return await active_learn(*args, **kwargs) + + @learner.as_stop_criterion( + metric_name=MEAN_SQUARED_ERROR_MSE, + threshold=MSE_THRESHOLD, + operator="<", + as_executable=False, + ) + async def criterion(*args, **kwargs): + return await check_mse(*args, **kwargs) + + # ── Attach tracker after all tasks are registered ───────────────────── + # on_start(manifest) fires here — the manifest is now complete. + learner.add_tracker( + MLflowTracker( + experiment_name="ROSE-Rosenbrock-Surrogate", + run_name="gp-adaptive-kernel", + ) + ) + + print("=" * 58) + print("ROSE + MLflowTracker — Rosenbrock GP Surrogate") + print("=" * 58) + + # Build LearnerConfig objects from the schedule + configs = { + it: LearnerConfig( + training=TaskConfig( + kwargs={ + "length_scale": ls, + "noise_level": nl, + } + ) + ) + for it, (ls, nl) in KERNEL_SCHEDULE.items() + } + + async for state in learner.start(max_iter=MAX_ITERATIONS): + print( + f"[iter {state.iteration:3d}] " + f"MSE={state.metric_value:.5f} " + f"labeled={state.get('n_labeled'):3d} " + f"ls={state.get('length_scale_used'):.2f} " + f"LML={state.get('log_marginal_likelihood'):+.1f}" + ) + + # Inject next kernel config if a schedule boundary is reached. + # MLflow sees current_config change automatically in the next + # on_iteration() call — no manual log_params() call needed. + next_iter = state.iteration + 1 + if next_iter in configs: + learner.set_next_config(configs[next_iter]) + ls, _ = KERNEL_SCHEDULE[next_iter] + print(f"kernel schedule: length_scale={ls}") + + await learner.shutdown() + DATA_FILE.unlink(missing_ok=True) + + print() + print("View results:") + print("mlflow ui --port 5000") + print("# Experiment: ROSE-Rosenbrock-Surrogate") + print("# Metrics: MEAN_SQUARED_ERROR_MSE, n_labeled, LML") + print("# Tags: stop_reason, final_iteration") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/reinforcement_learn/check_reward.py b/examples/reinforcement_learn/check_reward.py index 10f42c78..b7569b9c 100644 --- a/examples/reinforcement_learn/check_reward.py +++ b/examples/reinforcement_learn/check_reward.py @@ -1,10 +1,12 @@ +import os +import sys + import gym -import torch import numpy as np -import sys -import os +import torch from model import QNetwork + def reward(work_dir="."): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") @@ -25,7 +27,7 @@ def reward(work_dir="."): episode_rewards = [] - for ep in range(EPISODES): + for _ in range(EPISODES): state, _ = env.reset() done = False total_reward = 0 @@ -46,6 +48,7 @@ def reward(work_dir="."): mean_reward = np.mean(episode_rewards) print(f"{mean_reward:.2f}") + if __name__ == "__main__": work_dir = sys.argv[1] if len(sys.argv) > 1 else "." reward(work_dir) diff --git a/examples/reinforcement_learn/environment.py b/examples/reinforcement_learn/environment.py index dce617ab..bc336374 100644 --- a/examples/reinforcement_learn/environment.py +++ b/examples/reinforcement_learn/environment.py @@ -1,14 +1,13 @@ +import os +import sys import gym -import torch -import pickle import numpy as np -import sys -import os -import math -from collections import deque, namedtuple +import torch from model import QNetwork -from rose.rl.experience import Experience, ExperienceBank, create_experience + +from rose.rl.experience import ExperienceBank, create_experience + def episode(work_dir=".", filename=None, epsilon=0.1, epochs=5): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") @@ -35,7 +34,7 @@ def episode(work_dir=".", filename=None, epsilon=0.1, epochs=5): state, _ = env.reset() done = False episode_reward = 0 - + while not done: if np.random.random() < epsilon: state_tensor = torch.FloatTensor(state).unsqueeze(0).to(device) @@ -43,16 +42,16 @@ def episode(work_dir=".", filename=None, epsilon=0.1, epochs=5): action = model(state_tensor).argmax().item() else: action = env.action_space.sample() - + next_state, reward, done, _, _ = env.step(action) episode_reward += reward - + # Create and add experience experience = create_experience(state, action, reward, next_state, done) memory.add(experience) - + state = next_state - + print(f"Epoch {epoch + 1}/{epochs} - Episode reward: {episode_reward}") # Save memory @@ -65,10 +64,11 @@ def episode(work_dir=".", filename=None, epsilon=0.1, epochs=5): env.close() + if __name__ == "__main__": work_dir = sys.argv[1] if len(sys.argv) > 1 else "." epsilon = float(sys.argv[2]) if len(sys.argv) > 2 else 0.1 epochs = int(sys.argv[3]) if len(sys.argv) > 3 else 5 filename = sys.argv[4] if len(sys.argv) > 4 else None - - episode(work_dir, filename, epsilon, epochs) \ No newline at end of file + + episode(work_dir, filename, epsilon, epochs) diff --git a/examples/reinforcement_learn/merge.py b/examples/reinforcement_learn/merge.py index 5b566945..8dbfe3cc 100644 --- a/examples/reinforcement_learn/merge.py +++ b/examples/reinforcement_learn/merge.py @@ -1,33 +1,35 @@ import os import sys + from rose.rl.experience import ExperienceBank + def merge_banks(work_dir="."): """Find and merge all experience banks in directory.""" - + # Find all experience bank files bank_files = [] for filename in os.listdir(work_dir): if filename.startswith("experience_bank_") and filename.endswith(".pkl"): bank_files.append(os.path.join(work_dir, filename)) - + if not bank_files: print("No experience banks found!") return - + print(f"Found {len(bank_files)} experience banks") - + # Create merged bank and load all files merged = ExperienceBank() total = 0 - + for bank_file in bank_files: try: bank = ExperienceBank.load(bank_file) merged.merge_inplace(bank) total += len(bank) print(f" Merged {len(bank)} from {os.path.basename(bank_file)}") - except: + except Exception: print(f" Failed to load {bank_file}") for bank_file in bank_files: @@ -35,13 +37,14 @@ def merge_banks(work_dir="."): os.remove(bank_file) except Exception as e: print(f" Failed to delete {bank_file}: {e}") - + # Save merged bank output_path = os.path.join(work_dir, "experience_bank.pkl") - merged.save('.', output_path) - + merged.save(".", output_path) + print(f"Saved {len(merged)} total experiences to experience_bank.pkl") + if __name__ == "__main__": work_dir = sys.argv[1] if len(sys.argv) > 1 else "." - merge_banks(work_dir) \ No newline at end of file + merge_banks(work_dir) diff --git a/examples/reinforcement_learn/model.py b/examples/reinforcement_learn/model.py index 177f682c..746c85b9 100644 --- a/examples/reinforcement_learn/model.py +++ b/examples/reinforcement_learn/model.py @@ -1,11 +1,11 @@ - import torch import torch.nn as nn import torch.nn.functional as F + class QNetwork(nn.Module): def __init__(self, state_size, action_size, seed, fc1_units=64, fc2_units=64): - super(QNetwork, self).__init__() + super().__init__() self.seed = torch.manual_seed(seed) self.fc1 = nn.Linear(state_size, fc1_units) self.fc2 = nn.Linear(fc1_units, fc2_units) diff --git a/examples/reinforcement_learn/mujoco/mujocolearning.ipynb b/examples/reinforcement_learn/mujoco/mujocolearning.ipynb index 4d61cb99..38f2654f 100644 --- a/examples/reinforcement_learn/mujoco/mujocolearning.ipynb +++ b/examples/reinforcement_learn/mujoco/mujocolearning.ipynb @@ -10,16 +10,7 @@ } }, "outputs": [], - "source": [ - "import os\n", - "import sys\n", - "import time\n", - "\n", - "from radical.asyncflow import WorkflowEngine, RadicalExecutionBackend\n", - "\n", - "from rose.metrics import GREATER_THAN_THRESHOLD\n", - "from rose.rl.reinforcement_learner import SequentialReinforcementLearner" - ] + "source": "import os\nimport sys\nimport time\n\nfrom radical.asyncflow import WorkflowEngine\nfrom rhapsody.backends import RadicalExecutionBackend\n\nfrom rose.metrics import GREATER_THAN_THRESHOLD\nfrom rose.rl.reinforcement_learner import SequentialReinforcementLearner" }, { "cell_type": "code", diff --git a/examples/reinforcement_learn/mujoco/policy.py b/examples/reinforcement_learn/mujoco/policy.py index 61cabc76..b24ddad3 100644 --- a/examples/reinforcement_learn/mujoco/policy.py +++ b/examples/reinforcement_learn/mujoco/policy.py @@ -1,5 +1,6 @@ import numpy as np + class ReinforcePolicy: def __init__(self, state_dim, learning_rate=0.01): self.state_dim = state_dim @@ -18,7 +19,7 @@ def log_prob(self, state, action): z = np.dot(self.weights, state) mean = np.tanh(z) std = 0.1 - return -0.5 * ((action - mean)**2 / std**2 + np.log(2 * np.pi * std**2)) + return -0.5 * ((action - mean) ** 2 / std**2 + np.log(2 * np.pi * std**2)) def update(self, episode): G = 0 @@ -28,9 +29,9 @@ def update(self, episode): G = exp.reward + gamma * G returns.insert(0, G) - for exp, R in zip(episode, returns): + for exp, R in zip(episode, returns, strict=False): state = np.array(exp.state) z = np.dot(self.weights, state) - grad = (1 - np.tanh(z)**2) * state + grad = (1 - np.tanh(z) ** 2) * state logp_grad = grad * (exp.action - np.tanh(z)) / (0.1**2) self.weights += self.lr * logp_grad * R diff --git a/examples/reinforcement_learn/mujoco/simulate.py b/examples/reinforcement_learn/mujoco/simulate.py index 95437191..92a3b4c6 100644 --- a/examples/reinforcement_learn/mujoco/simulate.py +++ b/examples/reinforcement_learn/mujoco/simulate.py @@ -1,21 +1,27 @@ -import numpy as np -import os import argparse +import os +import pickle + from dm_control import suite from policy import ReinforcePolicy + from rose.rl.experience import Experience, ExperienceBank -import pickle + def run_cartpole_episode(env, policy, max_steps=200): time_step = env.reset() experiences = [] for _ in range(max_steps): - state = time_step.observation["position"].tolist() + time_step.observation["velocity"].tolist() + state = ( + time_step.observation["position"].tolist() + time_step.observation["velocity"].tolist() + ) action = policy.select_action(state, deterministic=False) time_step = env.step(action) - next_state = time_step.observation["position"].tolist() + time_step.observation["velocity"].tolist() + next_state = ( + time_step.observation["position"].tolist() + time_step.observation["velocity"].tolist() + ) reward = time_step.reward or 0.0 done = time_step.last() @@ -27,23 +33,23 @@ def run_cartpole_episode(env, policy, max_steps=200): return experiences + if __name__ == "__main__": # Parse command line arguments - parser = argparse.ArgumentParser(description='Simulate RL policy and collect experience data') - parser.add_argument('--data-dir', type=str, default='.') - parser.add_argument('--policy-file', type=str, default='trained_reinforce_policy.pkl') - parser.add_argument('--memory-file', type=str, default='replay_memory.pkl') - parser.add_argument('--episodes', type=int, default=20) - parser.add_argument('--max-steps', type=int, default=200) - parser.add_argument('--memory-size', type=int, default=1000) - + parser = argparse.ArgumentParser(description="Simulate RL policy and collect experience data") + parser.add_argument("--data-dir", type=str, default=".") + parser.add_argument("--policy-file", type=str, default="trained_reinforce_policy.pkl") + parser.add_argument("--memory-file", type=str, default="replay_memory.pkl") + parser.add_argument("--episodes", type=int, default=20) + parser.add_argument("--max-steps", type=int, default=200) + parser.add_argument("--memory-size", type=int, default=1000) + args, unknown = parser.parse_known_args() - # Construct full file paths policy_path = os.path.join(args.data_dir, args.policy_file) memory_path = os.path.join(args.data_dir, args.memory_file) - + env = suite.load(domain_name="cartpole", task_name="swingup") # Load policy diff --git a/examples/reinforcement_learn/mujoco/test_model.py b/examples/reinforcement_learn/mujoco/test_model.py index 35993859..7133acbc 100644 --- a/examples/reinforcement_learn/mujoco/test_model.py +++ b/examples/reinforcement_learn/mujoco/test_model.py @@ -1,15 +1,18 @@ -import os import argparse -from dm_control import suite -from policy import ReinforcePolicy +import os import pickle + import numpy as np +from dm_control import suite + def run_test_episode(env, policy): time_step = env.reset() total_reward = 0.0 for _ in range(500): - state = time_step.observation["position"].tolist() + time_step.observation["velocity"].tolist() + state = ( + time_step.observation["position"].tolist() + time_step.observation["velocity"].tolist() + ) action = policy.select_action(state, deterministic=True) time_step = env.step(action) total_reward += time_step.reward or 0.0 @@ -17,25 +20,33 @@ def run_test_episode(env, policy): break return total_reward + if __name__ == "__main__": # Parse command line arguments - parser = argparse.ArgumentParser(description='Test RL policy performance') - parser.add_argument('--data-dir', type=str, default='.', - help='Directory to load policy file from (default: current directory)') - parser.add_argument('--policy-file', type=str, default='trained_policy.pkl', - help='Name of the policy file to load (default: trained_policy.pkl)') - + parser = argparse.ArgumentParser(description="Test RL policy performance") + parser.add_argument( + "--data-dir", + type=str, + default=".", + help="Directory to load policy file from (default: current directory)", + ) + parser.add_argument( + "--policy-file", + type=str, + default="trained_policy.pkl", + help="Name of the policy file to load (default: trained_policy.pkl)", + ) + args, unknown = parser.parse_known_args() - # Construct full file path policy_path = os.path.join(args.data_dir, args.policy_file) - + # Load policy try: with open(policy_path, "rb") as f: policy = pickle.load(f) - except FileNotFoundError: + except FileNotFoundError: exit(1) env = suite.load(domain_name="cartpole", task_name="swingup") diff --git a/examples/reinforcement_learn/mujoco/train.py b/examples/reinforcement_learn/mujoco/train.py index 771607f1..b5107934 100644 --- a/examples/reinforcement_learn/mujoco/train.py +++ b/examples/reinforcement_learn/mujoco/train.py @@ -1,24 +1,24 @@ -import numpy as np -import os import argparse -from policy import ReinforcePolicy -from rose.rl.experience import Experience, ExperienceBank +import os import pickle +from policy import ReinforcePolicy + +from rose.rl.experience import ExperienceBank + if __name__ == "__main__": # Parse command line arguments parser = argparse.ArgumentParser() - parser.add_argument('--data-dir', type=str, default='.') - parser.add_argument('--memory-file', type=str, default='replay_memory.pkl') - parser.add_argument('--policy-file', type=str, default='trained_policy.pkl') + parser.add_argument("--data-dir", type=str, default=".") + parser.add_argument("--memory-file", type=str, default="replay_memory.pkl") + parser.add_argument("--policy-file", type=str, default="trained_policy.pkl") args, unknown = parser.parse_known_args() - # Construct full file paths memory_path = os.path.join(args.data_dir, args.memory_file) policy_path = os.path.join(args.data_dir, args.policy_file) - + print(f"Loading replay memory from: {memory_path}") memory = ExperienceBank.load(memory_path) @@ -30,9 +30,9 @@ else: policy = ReinforcePolicy(state_dim=5) print("Initialized new policy.") - + for epoch in range(200): - for i in range(5): + for _ in range(5): samples = memory.sample(64) policy.update(samples) print(f"Epoch {epoch}: updated policy with {len(samples)} samples") diff --git a/examples/reinforcement_learn/parallelexperience.ipynb b/examples/reinforcement_learn/parallelexperience.ipynb index 7a79ca0e..8841ac43 100644 --- a/examples/reinforcement_learn/parallelexperience.ipynb +++ b/examples/reinforcement_learn/parallelexperience.ipynb @@ -7,16 +7,7 @@ "id": "UA0MnVIPk8ha" }, "outputs": [], - "source": [ - "import os\n", - "import sys\n", - "import time\n", - "\n", - "from rose.metrics import GREATER_THAN_THRESHOLD\n", - "from rose.rl.reinforcement_learner import ParallelExperience\n", - "\n", - "from radical.asyncflow import WorkflowEngine, RadicalExecutionBackend" - ] + "source": "import os\nimport sys\nimport time\n\nfrom rose.metrics import GREATER_THAN_THRESHOLD\nfrom rose.rl.reinforcement_learner import ParallelExperience\n\nfrom radical.asyncflow import WorkflowEngine\nfrom rhapsody.backends import RadicalExecutionBackend" }, { "cell_type": "code", diff --git a/examples/reinforcement_learn/reinforcementlearning.ipynb b/examples/reinforcement_learn/reinforcementlearning.ipynb index ee4cf8e5..dc5bad6d 100644 --- a/examples/reinforcement_learn/reinforcementlearning.ipynb +++ b/examples/reinforcement_learn/reinforcementlearning.ipynb @@ -7,16 +7,7 @@ "id": "UA0MnVIPk8ha" }, "outputs": [], - "source": [ - "import os\n", - "import sys\n", - "import time\n", - "\n", - "from rose.metrics import GREATER_THAN_THRESHOLD\n", - "from rose.rl.reinforcement_learner import SequentialReinforcementLearner\n", - "\n", - "from radical.asyncflow import WorkflowEngine, RadicalExecutionBackend" - ] + "source": "import os\nimport sys\nimport time\n\nfrom rose.metrics import GREATER_THAN_THRESHOLD\nfrom rose.rl.reinforcement_learner import SequentialReinforcementLearner\n\nfrom radical.asyncflow import WorkflowEngine\nfrom rhapsody.backends import RadicalExecutionBackend" }, { "cell_type": "code", diff --git a/examples/reinforcement_learn/run_me.py b/examples/reinforcement_learn/run_me.py index 09369518..e80663da 100644 --- a/examples/reinforcement_learn/run_me.py +++ b/examples/reinforcement_learn/run_me.py @@ -3,37 +3,36 @@ import sys from concurrent.futures import ThreadPoolExecutor -from radical.asyncflow import ConcurrentExecutionBackend, WorkflowEngine +from radical.asyncflow import WorkflowEngine +from rhapsody.backends import ConcurrentExecutionBackend from rose.metrics import GREATER_THAN_THRESHOLD from rose.rl.reinforcement_learner import SequentialReinforcementLearner async def rose_rl(): - - engine = await ConcurrentExecutionBackend( - ThreadPoolExecutor() - ) + engine = await ConcurrentExecutionBackend(ThreadPoolExecutor()) asyncflow = await WorkflowEngine.create(engine) rl = SequentialReinforcementLearner(asyncflow) - code_path = f'{sys.executable} {os.getcwd()}' - data_path = os.path.join(os.getcwd(), 'data') + code_path = f"{sys.executable} {os.getcwd()}" + data_path = os.path.join(os.getcwd(), "data") os.makedirs(data_path, exist_ok=True) # Define and register the environment task @rl.environment_task - async def environment(*args): - return f'{code_path}/environment.py {data_path} 0.1 5 experience_bank.pkl' + async def environment(*args, task_description={"shell": True}): + return f"{code_path}/environment.py {data_path} 0.1 5 experience_bank.pkl" # Define and register the policy update task @rl.update_task - async def update(*args): - return f'{code_path}/update.py {data_path}' - - @rl.as_stop_criterion(metric_name='MODEL_REWARD', threshold=200, operator=GREATER_THAN_THRESHOLD) - async def check_reward(*args): - return f'{code_path}/check_reward.py {data_path}' - + async def update(*args, task_description={"shell": True}): + return f"{code_path}/update.py {data_path}" + + @rl.as_stop_criterion( + metric_name="MODEL_REWARD", threshold=200, operator=GREATER_THAN_THRESHOLD + ) + async def check_reward(*args, task_description={"shell": True}): + return f"{code_path}/check_reward.py {data_path}" # Start the reinforcement learning process async for state in rl.start(): diff --git a/examples/reinforcement_learn/update.py b/examples/reinforcement_learn/update.py index 0a21653b..e422e00f 100644 --- a/examples/reinforcement_learn/update.py +++ b/examples/reinforcement_learn/update.py @@ -1,20 +1,19 @@ +import os +import sys + +import gym import torch import torch.nn.functional as F import torch.optim as optim -import numpy as np -import pickle -import random -import gym -import os -import sys -from collections import deque, namedtuple from model import QNetwork -from rose.rl.experience import Experience, ExperienceBank + +from rose.rl.experience import ExperienceBank + def update(work_dir=".", memory_file="experience_bank.pkl"): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") ENV_NAME = "CartPole-v1" - + # Config MODEL_PATH = os.path.join(work_dir, "dqn_model.pth") BATCH_SIZE = 64 @@ -29,7 +28,7 @@ def update(work_dir=".", memory_file="experience_bank.pkl"): if len(memory) < BATCH_SIZE: print(f"Not enough experiences for training. Need at least {BATCH_SIZE}, got {len(memory)}") return - + env = gym.make(ENV_NAME) state_size = env.observation_space.shape[0] action_size = env.action_space.n @@ -48,7 +47,7 @@ def update(work_dir=".", memory_file="experience_bank.pkl"): for epoch in range(EPOCHS): # Sample batch from experience bank batch = memory.sample(BATCH_SIZE, replace=True) - + states = torch.FloatTensor([exp.state for exp in batch]).to(device) actions = torch.LongTensor([[exp.action] for exp in batch]).to(device) rewards = torch.FloatTensor([[exp.reward] for exp in batch]).to(device) @@ -66,11 +65,12 @@ def update(work_dir=".", memory_file="experience_bank.pkl"): optimizer.step() if (epoch + 1) % 10 == 0: - print(f"Epoch {epoch+1}/{EPOCHS}, Loss: {loss.item():.4f}") + print(f"Epoch {epoch + 1}/{EPOCHS}, Loss: {loss.item():.4f}") torch.save(model.state_dict(), MODEL_PATH) print("Model saved.") + if __name__ == "__main__": work_dir = sys.argv[1] if len(sys.argv) > 1 else "." update(work_dir) diff --git a/examples/uq_active_learn/run_me.py b/examples/uq_active_learn/run_me.py deleted file mode 100644 index 766afb0f..00000000 --- a/examples/uq_active_learn/run_me.py +++ /dev/null @@ -1,188 +0,0 @@ -# run_me.py -import asyncio -import json -import os -import subprocess -import sys -from concurrent.futures import ProcessPoolExecutor -from pathlib import Path - -from radical.asyncflow import ( - ConcurrentExecutionBackend, - RadicalExecutionBackend, - WorkflowEngine, -) - -from rose import TaskConfig -from rose.metrics import MODEL_ACCURACY, PREDICTIVE_ENTROPY -from rose.uq.uq_active_learner import ParallelUQLearner -from rose.uq.uq_learner import UQLearnerConfig - -TEST_RADICAL = False -TEST_CUSTOM_UQ = False - -if TEST_CUSTOM_UQ: - UQ_METRIC_NAME = 'custom_uq' #if you want to use custom metric defined in check_uq.py -else: - UQ_METRIC_NAME = PREDICTIVE_ENTROPY - - -ACC_THRESHOLD = 0.5 -UQ_THRESHOLD = 1.5 -ITERATIONS = 3 -PIPELINES = ['UQ1', 'UQ2'] -TASK_TYPE = 'classification' -USECASE = 'ENSEMBLE' - # Options: 'Bayesian', 'SINGLE_MODEL', 'ENSEMBLE' -UQ_QUERY_SIZE = 1 - -home_dir = os.environ.get('ROSE_HOME', subprocess.check_output(["pwd"], text=True).strip()) - -async def uq_learner(): - - if USECASE == 'Bayesian': - NUM_PREDICTION = 1 - MODELS = ['BayesianNN'] - elif USECASE == 'SINGLE_MODEL': - NUM_PREDICTION = 2 - MODELS = ['MC_Dropout_CNN'] - elif USECASE == 'ENSEMBLE': - NUM_PREDICTION = 2 - MODELS = ['MC_Dropout_CNN', 'MC_Dropout_MLP'] - else: - return - - if TEST_RADICAL: - RESOURCES = { - 'runtime': 300, - 'resource': 'local.localhost', - #'resource': 'purdue.anvil', - 'cores': 16 - } - engine = await RadicalExecutionBackend(RESOURCES) - else: - engine = await ConcurrentExecutionBackend(ProcessPoolExecutor()) - - asyncflow = await WorkflowEngine.create(engine) - - learner = ParallelUQLearner(asyncflow) - code_path = f'{sys.executable} {os.getcwd()}' - - # Define and register the simulation task - @learner.simulation_task() - async def simulation(*args, **kwargs): - learner_name = kwargs.get("--learner_name") - train_batch = kwargs.get("--train_batch") - home_dir = kwargs.get("--home_dir") - return f'{code_path}/simulation.py --train_batch {train_batch} --learner_name {learner_name} --home_dir {home_dir}' - - # Define and register the training task for each model - @learner.training_task() - async def training(*args, **kwargs): - learner_name = kwargs.get("--learner_name") - model_name = kwargs.get("--model_name") - epochs = kwargs.get("--epochs") - home_dir = kwargs.get("--home_dir") - return f'{code_path}/training.py --model_name {model_name} --learner_name {learner_name} --epochs {epochs} --home_dir {home_dir}' - - # Define and register the predict task for each model - @learner.prediction_task() - async def prediction(*args, **kwargs): - learner_name = kwargs.get("--learner_name") - model_name = kwargs.get("--model_name") - iteration = kwargs.get("--iteration") - prediction_dir = kwargs.get("--prediction_dir") - home_dir = kwargs.get("--home_dir") - return f'{code_path}/predict.py --model_name {model_name} ' \ - f'--prediction_dir {prediction_dir} ' \ - f'--iteration {iteration} --learner_name {learner_name} --home_dir {home_dir}' - - # Define and register the active learning task with UQ metrics - @learner.active_learn_task() - async def active_learn(*args, **kwargs): - learner_name = kwargs.get("--learner_name") - home_dir = kwargs.get("--home_dir") - return f'{code_path}/active_learn.py --learner_name {learner_name} ' \ - f'--home_dir {home_dir}' - - # Defining the stop criterion with a metric (MODEL_ACCURACY in this case) - @learner.as_stop_criterion(metric_name=MODEL_ACCURACY, threshold=ACC_THRESHOLD) - async def check_accuracy(*args, **kwargs): - model_name = kwargs.get("--model_name") - home_dir = kwargs.get("--home_dir") - return f'{code_path}/check_accuracy.py --model_name {model_name} ' \ - f'--home_dir {home_dir}' - - # Defining the stop criterion with a metric (MODEL_ACCURACY in this case) - @learner.uncertainty_quantification(uq_metric_name=UQ_METRIC_NAME, - threshold=UQ_THRESHOLD, - query_size=UQ_QUERY_SIZE) - async def check_uq(*args, **kwargs): - - home_dir = kwargs.get("--home_dir") - predict_dir = kwargs.get("--prediction_dir") - learner_name = kwargs.get("--learner_name") - query_size = kwargs.get("--query_size") - uq_metric_name = kwargs.get("--uq_metric_name") - task_type = kwargs.get("--task_type") - - return f'{code_path}/check_uq.py --learner_name {learner_name} --predict_dir {predict_dir} ' \ - f'--query_size {query_size} ' \ - f'--uq_metric_name {uq_metric_name} --task_type {task_type} --home_dir {home_dir}' - - - - learner_configs = {} - for PIPELINE in PIPELINES: - learner_configs[PIPELINE] = UQLearnerConfig( - simulation=TaskConfig(kwargs={ - '--home_dir': home_dir, - '--train_batch': 50, - '--learner_name': f'{PIPELINE}'}), - - training=TaskConfig(kwargs={ - '--epochs': 10, - '--home_dir': home_dir, - '--learner_name': f'{PIPELINE}'}), - - prediction=TaskConfig(kwargs={ - '--home_dir': home_dir, - '--learner_name': f'{PIPELINE}', - '--prediction_dir': f'{PIPELINE}_prediction'}), - - uncertainty=TaskConfig(kwargs={ - '--uq_metric_name': UQ_METRIC_NAME, - '--task_type': TASK_TYPE, - '--query_size': UQ_QUERY_SIZE, - '--learner_name': f'{PIPELINE}', - '--home_dir': home_dir, - '--prediction_dir': f'{PIPELINE}_prediction'}), - - criterion=TaskConfig(kwargs={ - '--prediction_dir': f'{PIPELINE}_prediction'}), - - active_learn=TaskConfig(kwargs={ - '--learner_name': f'{PIPELINE}', - '--home_dir': home_dir, - })) - - - # Start the UQ active learning process - results = await learner.start( - learner_names=PIPELINES, - model_names=MODELS, - learner_configs=learner_configs, - max_iter=ITERATIONS, - num_predictions=NUM_PREDICTION - ) - - print('Learning process is done.') - print(f"Results: {results}") - - with open(Path(os.getcwd(), 'UQ_training_results.json'), 'w') as f: - json.dump(results, f, indent=4) - - await learner.shutdown() - -if __name__ == "__main__": - asyncio.run(uq_learner()) diff --git a/examples/uq_active_learn/simulation.py b/examples/uq_active_learn/simulation.py deleted file mode 100644 index 0623c30f..00000000 --- a/examples/uq_active_learn/simulation.py +++ /dev/null @@ -1,47 +0,0 @@ -# simulation.py -import json -import argparse -import numpy as np -from pathlib import Path - -def simulate(home_dir, samples_suffix, pool_suffix, train_batch, learner_name): - - pool_file = Path(home_dir, learner_name + pool_suffix) - samples_file = Path(home_dir, learner_name + samples_suffix) - - try: - from torchvision import datasets, transforms - data_dir = Path(home_dir, 'mnist_data') - transform = transforms.Compose([transforms.ToTensor()]) - mnist_train = datasets.MNIST(root=data_dir, - train=True, - transform=transform, - download=False # Do NOT download again - ) - indices = np.arange(len(mnist_train)) - except: - # In case of any error, create dummy indices - print(f"Load dummy indices {train_batch*2} initial samples.") - indices = np.arange(train_batch*2) - - np.random.seed(42) - np.random.shuffle(indices) - - X_labeled_idx = indices[:train_batch] - X_pool_idx = indices[train_batch:] - - with open(samples_file, 'w') as f: - json.dump(X_labeled_idx.tolist(), f) - with open(pool_file, 'w') as f: - json.dump(X_pool_idx.tolist(), f) - - print(f"Selected {train_batch} initial samples.") - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Prediction argument parser") - parser.add_argument('--train_batch', type=int, help='Number of labels used for initial training') - parser.add_argument('--learner_name', type=str, help='Name of the learner') - parser.add_argument('--home_dir', type=str, help='Home directory for the project') - args = parser.parse_args() - - simulate(args.home_dir, "_samples.json", "_pool.json", train_batch=args.train_batch, learner_name=args.learner_name) diff --git a/examples/use_cases/neutron-scattering/run_me.py b/examples/use_cases/neutron-scattering/run_me.py index ab7d8b0f..50635bd3 100644 --- a/examples/use_cases/neutron-scattering/run_me.py +++ b/examples/use_cases/neutron-scattering/run_me.py @@ -1,67 +1,82 @@ import os import sys -import time -verbose = os.environ.get('RADICAL_PILOT_VERBOSE', 'REPORT') -os.environ['RADICAL_PILOT_VERBOSE'] = verbose - -from rose.learner import Learner +verbose = os.environ.get("RADICAL_PILOT_VERBOSE", "REPORT") +os.environ["RADICAL_PILOT_VERBOSE"] = verbose from radical.asyncflow import WorkflowEngine -from radical.asyncflow import RadicalExecutionBackend +from rhapsody.backends import RadicalExecutionBackend + +from rose.learner import Learner -seed=20030 -num_sample=4500 -num_sample_val=((num_sample / 2)) -num_sample_test=((num_sample / 2)) -num_sample_study=num_sample -num_al_sample=((num_sample * 3)) -batch_size=512 -epochs=[400,300,250,200] +seed = 20030 +num_sample = 4500 +num_sample_val = num_sample / 2 +num_sample_test = num_sample / 2 +num_sample_study = num_sample +num_al_sample = num_sample * 3 +batch_size = 512 +epochs = [400, 300, 250, 200] -NNODES=1 +NNODES = 1 -nthread=32 -nthread_tot=( NNODES * nthread ) +nthread = 32 +nthread_tot = NNODES * nthread -nthread_study=22 -nthread_study_tot=( NNODES * nthread_study ) +nthread_study = 22 +nthread_study_tot = NNODES * nthread_study -nrank_ml=4 -nrank_ml_tot=( NNODES * nrank_ml ) +nrank_ml = 4 +nrank_ml_tot = NNODES * nrank_ml -ngpus=(NNODES * 4) +ngpus = NNODES * 4 async def bootstrap(): - os.system(f'{code_path}/prepare_data_dir_pm.py --seed {seed}') - - bootstrap=[] - base = sample_simulation(f'{num_sample} {seed} \ + os.system(f"{code_path}/prepare_data_dir_pm.py --seed {seed}") + + bootstrap = [] + base = sample_simulation( + f"{num_sample} {seed} \ {data_dir}/base/config/config_1001460_cubic.txt \ {data_dir}/base/config/config_1522004_trigonal.txt \ - {data_dir}/base/config/config_1531431_tetragonal.txt') - val = sample_simulation(f'{num_sample} {seed-1} \ + {data_dir}/base/config/config_1531431_tetragonal.txt" + ) + val = sample_simulation( + f"{num_sample} {seed - 1} \ {data_dir}/validation/config/config_1001460_cubic.txt \ {data_dir}/validation/config/config_1522004_trigonal.txt \ - {data_dir}/validation/config/config_1531431_tetragonal.txt') - test = sample_simulation(f'{num_sample} {seed+1} \ + {data_dir}/validation/config/config_1531431_tetragonal.txt" + ) + test = sample_simulation( + f"{num_sample} {seed + 1} \ {data_dir}/test/config/config_1001460_cubic.txt \ {data_dir}/test/config/config_1522004_trigonal.txt \ - {data_dir}/test/config/config_1531431_tetragonal.txt') - study = sweep_simulation(f'{num_sample_study} \ + {data_dir}/test/config/config_1531431_tetragonal.txt" + ) + study = sweep_simulation( + f"{num_sample_study} \ {data_dir}/study/config/config_1001460_cubic.txt \ {data_dir}/study/config/config_1522004_trigonal.txt \ - {data_dir}/study/config/config_1531431_tetragonal.txt') + {data_dir}/study/config/config_1531431_tetragonal.txt" + ) bootstrap.append(base) bootstrap.append(val) bootstrap.append(test) bootstrap.append(study) - for shape in ['cubic', 'trigonal', 'tetragonal']: - merge_base = merge_preprocess(f'{data_dir}/base/data {shape} {nthread_tot}', base) - merge_val = merge_preprocess(f'{data_dir}/validation/data {shape} {nthread_tot}', val) - merge_test = merge_preprocess(f'{data_dir}/test/data {shape} {nthread_tot}', test) - merge_study = merge_preprocess(f'{data_dir}/study/data {shape} {nthread_tot}', study) + for shape in ["cubic", "trigonal", "tetragonal"]: + merge_base = merge_preprocess( + f"{data_dir}/base/data {shape} {nthread_tot}", base + ) + merge_val = merge_preprocess( + f"{data_dir}/validation/data {shape} {nthread_tot}", val + ) + merge_test = merge_preprocess( + f"{data_dir}/test/data {shape} {nthread_tot}", test + ) + merge_study = merge_preprocess( + f"{data_dir}/study/data {shape} {nthread_tot}", study + ) bootstrap.append(merge_base) bootstrap.append(merge_val) bootstrap.append(merge_test) @@ -71,16 +86,14 @@ async def bootstrap(): async def main(): - - engine = await RadicalExecutionBackend( - {'resource': 'local.localhost'}) + engine = await RadicalExecutionBackend({"resource": "local.localhost"}) asyncflow = await WorkflowEngine.create(engine) acl = Learner(asyncflow) - code_path = f'{sys.executable} {os.getcwd()}' + code_path = f"{sys.executable} {os.getcwd()}" - data_dir= f'{os.getcwd()}/data/seed_{seed}' + data_dir = f"{os.getcwd()}/data/seed_{seed}" # The scripts used here are a dummy representative of the actual use case tasks, and # dont actually run the simulation sample @@ -88,70 +101,82 @@ async def main(): # Define and register the simulation task @acl.simulation_task async def simulation(*args): - #return f'{code_path}/simulation_resample.py' - return f'{code_path}/replacement_sim.py' + # return f'{code_path}/simulation_resample.py' + return f"{code_path}/replacement_sim.py" # Define and register a utility task @acl.utility_task async def merge_preprocess(*args): # return f'{code_path}/merge_preprocess_hdf5.py' - return f'{code_path}/replacement_sim.py' + return f"{code_path}/replacement_sim.py" # Define and register the training task @acl.training_task async def training(*args): # return f'{code_path}/train.py' - return f'{code_path}/replacement_sim.py' + return f"{code_path}/replacement_sim.py" # Define and register the active learning task @acl.active_learn_task async def active_learn(*args): # return f'{code_path}/active_learning.py' - return f'{code_path}/replacement_sim.py' + return f"{code_path}/replacement_sim.py" # Prepare Data # Define the simulation sample task @acl.utility_task async def sample_simulation(*args): # task = f'{code_path}/simulation_sample.py' - return f'{code_path}/replacement_sim.py' + return f"{code_path}/replacement_sim.py" - #simulation sweep task + # simulation sweep task @acl.utility_task async def sweep_simulation(*args): - # task = f'{code_path}/simulation_sweep.py' - return f'{code_path}/replacement_sim.py' + # task = f'{code_path}/simulation_sweep.py' + return f"{code_path}/replacement_sim.py" # Custom training loop using active learning async def start(): for acl_iter in range(4): - print(f'Starting Iteration-{acl_iter}') + print(f"Starting Iteration-{acl_iter}") simulations = [] if acl_iter != 0: - sim = simulation(f'{seed+2} \ + sim = simulation( + f"{seed + 2} \ {data_dir}/AL_phase_{acl_iter}/config/config_1001460_cubic.txt \ {data_dir}/study/data/cubic_1001460_cubic.hdf5 \ {data_dir}/AL_phase_{acl_iter}/config/config_1522004_trigonal.txt \ {data_dir}/study/data/trigonal_1522004_trigonal.hdf5 \ {data_dir}/AL_phase_{acl_iter}/config/config_1531431_tetragonal.txt \ - {data_dir}/study/data/tetragonal_1531431_tetragonal.hdf5') + {data_dir}/study/data/tetragonal_1531431_tetragonal.hdf5" + ) simulations.append(sim) - for shape in ['cubic', 'trigonal', 'tetragonal']: - merge=merge_preprocess(f'{data_dir}/AL_phase_{acl_iter}/data cubic {nthread_tot}', sim) + for shape in ["cubic", "trigonal", "tetragonal"]: + merge = merge_preprocess( + f"{data_dir}/AL_phase_{acl_iter}/data cubic {nthread_tot}", sim + ) simulations.append(merge) await asyncio.gather(*simulations) # Now run training and active_learn - train = training(f'--batch_size {batch_size} \ + train = training( + f"--batch_size {batch_size} \ --epochs {epochs[acl_iter]} \ --seed {seed} \ --device=cpu \ --num_threads {nthread} \ --phase_idx {acl_iter} \ --data_dir {data_dir} \ - --shared_file_dir {data_dir}', *simulations) - active = active_learn(f'--seed {seed+3} --num_new_sample {num_al_sample} --policy uncertainty', simulations, train) + --shared_file_dir {data_dir}", + *simulations, + ) + active = active_learn( + f"--seed {seed + 3} --num_new_sample {num_al_sample} --policy uncertainty", + simulations, + train, + ) await active + # invoke the custom/user-defined start() method await bootstrap() await start() diff --git a/examples/use_cases/neutron-scattering/scripts/active_learning.py b/examples/use_cases/neutron-scattering/scripts/active_learning.py index f60bb931..4f47ce77 100644 --- a/examples/use_cases/neutron-scattering/scripts/active_learning.py +++ b/examples/use_cases/neutron-scattering/scripts/active_learning.py @@ -1,37 +1,39 @@ -import torch -import numpy as np import argparse import random from collections import OrderedDict import model as mdp +import numpy as np +import torch -def get_freq(args, do_print = True): +def get_freq(args, do_print=True): torch.manual_seed(args.seed) random.seed(args.seed) np.random.seed(args.seed) - checkpoint = torch.load('ckpt.pth') - state_dict = checkpoint['model_state_dict'] + checkpoint = torch.load("ckpt.pth") + state_dict = checkpoint["model_state_dict"] new_state_dict = OrderedDict() for k, v in state_dict.items(): - name = k[7:] # remove 'module.' of DataParallel/DistributedDataParallel + name = k[7:] # remove 'module.' of DataParallel/DistributedDataParallel new_state_dict[name] = v - - model = mdp.FullModel(len_input = 2806, num_hidden = 256, num_output = 3+1, num_classes = 3) + + model = mdp.FullModel( + len_input=2806, num_hidden=256, num_output=3 + 1, num_classes=3 + ) model.load_state_dict(new_state_dict) - + model.eval() model = model.cpu() x_study_torch = torch.load("x_study_torch.pt") with torch.no_grad(): y_pred_torch_class, y_pred_torch_regression = model(x_study_torch) - + y_pred_np = y_pred_torch_regression.detach().numpy().reshape(-1, 4) - print("sum of y_pred_np[:,3] = ", np.sum(y_pred_np[:,3])) - w_reg = np.exp(y_pred_np[:,3]) + print("sum of y_pred_np[:,3] = ", np.sum(y_pred_np[:, 3])) + w_reg = np.exp(y_pred_np[:, 3]) w_reg = w_reg.astype(np.float64) w_reg = w_reg / np.sum(w_reg) print("sum of y_pred_torch_class = ", np.sum(y_pred_torch_class.detach().numpy())) @@ -48,34 +50,44 @@ def get_freq(args, do_print = True): w = 0.5 * w_reg + 0.5 * w_class print(np.sum(w)) freq = np.random.multinomial(args.num_new_sample, w) - + if do_print: with np.printoptions(threshold=np.inf): print("logits = ", y_pred_torch_class.numpy()) print("prob = ", prob) print("entropy = ", entropy) - print("logsig2 = ", y_pred_np[:,3]) + print("logsig2 = ", y_pred_np[:, 3]) print("sig2 after norm = ", w_reg) print("entropy after norm = ", w_class) print("freq = ", freq) print("freq.shape = ", freq.shape) print("freq.sum = ", np.sum(freq)) - - np.save('AL-freq.npy', freq) + + np.save("AL-freq.npy", freq) + def main(): - parser = argparse.ArgumentParser(description='Exalearn_AL_v1') - - parser.add_argument('--seed', type=int, required=True, - help='random seed (default: 42)') - parser.add_argument('--num_new_sample', type=int, required=True, - help='number of new samples for next simulation (default: 2000)') - parser.add_argument('--policy', choices=['uncertainty', 'loss', 'random'], - help='AL policy used. uncertainty is the one we want to look at, random means randomly sample') + parser = argparse.ArgumentParser(description="Exalearn_AL_v1") + + parser.add_argument( + "--seed", type=int, required=True, help="random seed (default: 42)" + ) + parser.add_argument( + "--num_new_sample", + type=int, + required=True, + help="number of new samples for next simulation (default: 2000)", + ) + parser.add_argument( + "--policy", + choices=["uncertainty", "loss", "random"], + help="AL policy used. uncertainty is the one we want to look at, random means randomly sample", + ) args = parser.parse_args() get_freq(args, True) + if __name__ == "__main__": main() diff --git a/examples/use_cases/neutron-scattering/scripts/compute_kernel.py b/examples/use_cases/neutron-scattering/scripts/compute_kernel.py index 1c6f23d5..68a2c786 100644 --- a/examples/use_cases/neutron-scattering/scripts/compute_kernel.py +++ b/examples/use_cases/neutron-scattering/scripts/compute_kernel.py @@ -1,115 +1,135 @@ import torch -import torch.utils.data.distributed import torch.distributed as dist -from torch.nn.parallel import DistributedDataParallel as DDP -import numpy as np +import torch.utils.data.distributed + def metric_average(val, size, name): # Sum everything and divide by total size: - dist.all_reduce(val,op=dist.ReduceOp.SUM) + dist.all_reduce(val, op=dist.ReduceOp.SUM) val /= size return val -def train(epoch, rank, size, - model, optimizer, - train_loader, - train_sampler, - criterion_reg, criterion_class, - lr_scheduler, - on_gpu, - log_interval, - loss_list): - + +def train( + epoch, + rank, + size, + model, + optimizer, + train_loader, + train_sampler, + criterion_reg, + criterion_class, + lr_scheduler, + on_gpu, + log_interval, + loss_list, +): model.train() train_sampler.set_epoch(epoch) if rank == 0: - print("lr = ", optimizer.param_groups[0]['lr']) + print("lr = ", optimizer.param_groups[0]["lr"]) if epoch % log_interval == 0: - running_loss = torch.tensor(0.0) + running_loss = torch.tensor(0.0) running_loss1 = torch.tensor(0.0) running_loss2 = torch.tensor(0.0) if on_gpu: - running_loss, running_loss1, running_loss2 = running_loss.cuda(), running_loss1.cuda(), running_loss2.cuda() + running_loss, running_loss1, running_loss2 = ( + running_loss.cuda(), + running_loss1.cuda(), + running_loss2.cuda(), + ) for batch_idx, current_batch in enumerate(train_loader): if on_gpu: inp, current_batch_y = current_batch[0].cuda(), current_batch[1].cuda() else: - inp, current_batch_y = current_batch[0], current_batch[1] + inp, current_batch_y = current_batch[0], current_batch[1] optimizer.zero_grad() class_output, regression_output = model(inp) - regression_gndtruth = current_batch_y[:,0:3] - class_gndtruth = current_batch_y[:,3].type(torch.LongTensor) - if on_gpu: #Seems like reset the tensor type moves data from GPU back to CPU, so need to move it to device again! + regression_gndtruth = current_batch_y[:, 0:3] + class_gndtruth = current_batch_y[:, 3].type(torch.LongTensor) + if on_gpu: # Seems like reset the tensor type moves data from GPU back to CPU, so need to move it to device again! class_gndtruth = class_gndtruth.cuda() loss1 = criterion_reg(regression_output, regression_gndtruth) loss2 = criterion_class(class_output, class_gndtruth) - loss = loss1 + loss2 + loss = loss1 + loss2 loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=50.0) -# max_grad = max(param.grad.abs().max() for param in model.parameters() if param.grad is not None) -# print("TW: Looking at param grad, rank = {}, batch_idx = {}, |grad|_max = {}".format(rank, batch_idx, max_grad)) + # max_grad = max(param.grad.abs().max() for param in model.parameters() if param.grad is not None) + # print("TW: Looking at param grad, rank = {}, batch_idx = {}, |grad|_max = {}".format(rank, batch_idx, max_grad)) optimizer.step() if epoch % log_interval == 0: - running_loss += loss.item() + running_loss += loss.item() running_loss1 += loss1.item() running_loss2 += loss2.item() lr_scheduler.step() if epoch % log_interval == 0: - running_loss = running_loss / len(train_loader) + running_loss = running_loss / len(train_loader) running_loss1 = running_loss1 / len(train_loader) running_loss2 = running_loss2 / len(train_loader) - loss_avg = metric_average(running_loss, size, 'running_loss') - loss1_avg = metric_average(running_loss1, size, 'running_loss1') - loss2_avg = metric_average(running_loss2, size, 'running_loss2') + loss_avg = metric_average(running_loss, size, "running_loss") + loss1_avg = metric_average(running_loss1, size, "running_loss1") + loss2_avg = metric_average(running_loss2, size, "running_loss2") if rank == 0: - print("epoch: {}, Average loss_reg: {:15.8f}, loss_class: {:15.8f}, loss_tot: {:15.8f}".format(epoch, loss1_avg, loss2_avg, loss_avg)) + print( + f"epoch: {epoch}, Average loss_reg: {loss1_avg:15.8f}, loss_class: {loss2_avg:15.8f}, loss_tot: {loss_avg:15.8f}" + ) loss_list.append(loss_avg) -def test(epoch, rank, size, - model, - test_loader, - criterion_reg, criterion_class, - on_gpu, - log_interval, - loss_list): +def test( + epoch, + rank, + size, + model, + test_loader, + criterion_reg, + criterion_class, + on_gpu, + log_interval, + loss_list, +): model.eval() - - test_loss = torch.tensor(0.0) + + test_loss = torch.tensor(0.0) test_loss1 = torch.tensor(0.0) test_loss2 = torch.tensor(0.0) if on_gpu: - test_loss, test_loss1, test_loss2 = test_loss.cuda(), test_loss1.cuda(), test_loss2.cuda() - + test_loss, test_loss1, test_loss2 = ( + test_loss.cuda(), + test_loss1.cuda(), + test_loss2.cuda(), + ) + for batch_idx, current_batch in enumerate(test_loader): if on_gpu: inp, current_batch_y = current_batch[0].cuda(), current_batch[1].cuda() else: - inp, current_batch_y = current_batch[0], current_batch[1] + inp, current_batch_y = current_batch[0], current_batch[1] with torch.no_grad(): y_pred_torch_class, y_pred_torch_regression = model(inp) - regression_gndtruth = current_batch_y[:,0:3] - class_gndtruth = current_batch_y[:,3].type(torch.LongTensor) - if on_gpu: #Seems like reset the tensor type moves data from GPU back to CPU, so need to move it to device again! + regression_gndtruth = current_batch_y[:, 0:3] + class_gndtruth = current_batch_y[:, 3].type(torch.LongTensor) + if on_gpu: # Seems like reset the tensor type moves data from GPU back to CPU, so need to move it to device again! class_gndtruth = class_gndtruth.cuda() test_loss1 += criterion_reg(y_pred_torch_regression, regression_gndtruth).item() test_loss2 += criterion_class(y_pred_torch_class, class_gndtruth).item() - + test_loss1 = test_loss1 / len(test_loader) test_loss2 = test_loss2 / len(test_loader) - test_loss = test_loss1 + test_loss2 + test_loss = test_loss1 + test_loss2 - loss_avg = metric_average(test_loss, size, "loss_avg") + loss_avg = metric_average(test_loss, size, "loss_avg") loss_avg1 = metric_average(test_loss1, size, "loss_avg1") loss_avg2 = metric_average(test_loss2, size, "loss_avg2") @@ -117,43 +137,43 @@ def test(epoch, rank, size, if epoch % log_interval == 0: if rank == 0: - print("epoch: {}, Average test_loss_reg: {:15.8f}, test_loss_class: {:15.8f}, test_loss_tot: {:15.8f}".format(epoch, loss_avg1, loss_avg2, loss_avg)) + print( + f"epoch: {epoch}, Average test_loss_reg: {loss_avg1:15.8f}, test_loss_class: {loss_avg2:15.8f}, test_loss_tot: {loss_avg:15.8f}" + ) loss_list.append(loss_avg) return loss_avg -#Here criterion_reg could be different from that in train since it does not have uncertainty term -def validation(rank, size, - model, - test_loader, - criterion_reg, criterion_class, - on_gpu): +# Here criterion_reg could be different from that in train since it does not have uncertainty term +def validation(rank, size, model, test_loader, criterion_reg, criterion_class, on_gpu): model.eval() - diff0 = torch.tensor(0.0) + diff0 = torch.tensor(0.0) sigma2 = torch.tensor(0.0) class_loss = torch.tensor(0.0) if on_gpu: - diff0, sigma2, class_loss = diff0.cuda(), sigma2.cuda(), class_loss.cuda() - + diff0, sigma2, class_loss = diff0.cuda(), sigma2.cuda(), class_loss.cuda() + for batch_idx, current_batch in enumerate(test_loader): if on_gpu: inp, current_batch_y = current_batch[0].cuda(), current_batch[1].cuda() else: - inp, current_batch_y = current_batch[0], current_batch[1] + inp, current_batch_y = current_batch[0], current_batch[1] with torch.no_grad(): y_pred_torch_class, y_pred_torch_regression = model(inp) - regression_gndtruth = current_batch_y[:,0:3] - class_gndtruth = current_batch_y[:,3].type(torch.LongTensor) - if on_gpu: #Seems like reset the tensor type moves data from GPU back to CPU, so need to move it to device again! + regression_gndtruth = current_batch_y[:, 0:3] + class_gndtruth = current_batch_y[:, 3].type(torch.LongTensor) + if on_gpu: # Seems like reset the tensor type moves data from GPU back to CPU, so need to move it to device again! class_gndtruth = class_gndtruth.cuda() - diff0 += criterion_reg(y_pred_torch_regression[:,0:3], regression_gndtruth).item() + diff0 += criterion_reg( + y_pred_torch_regression[:, 0:3], regression_gndtruth + ).item() class_loss += criterion_class(y_pred_torch_class, class_gndtruth).item() - sigma2 += torch.mean(torch.exp(y_pred_torch_regression[:,3])) - + sigma2 += torch.mean(torch.exp(y_pred_torch_regression[:, 3])) + diff0 = diff0 / len(test_loader) class_loss = class_loss / len(test_loader) sigma2 = sigma2 / len(test_loader) @@ -166,5 +186,5 @@ def validation(rank, size, print("Avg diff on test set = ", diff0) print("Avg sigma^2 on test set = ", class_loss) print("Avg class loss on test set = ", sigma2) - + return diff0, sigma2, class_loss diff --git a/examples/use_cases/neutron-scattering/scripts/merge_preprocess_hdf5.py b/examples/use_cases/neutron-scattering/scripts/merge_preprocess_hdf5.py index b1a5c361..fa6c144c 100644 --- a/examples/use_cases/neutron-scattering/scripts/merge_preprocess_hdf5.py +++ b/examples/use_cases/neutron-scattering/scripts/merge_preprocess_hdf5.py @@ -1,23 +1,27 @@ +import sys + import h5py import numpy as np -import sys, os -#We currently handle cubic, trigonal and tetragonal sym, and we handle them separately here, and only solve the y-axis (parameter) -#We create Y-data with size n*4 and save to file -#For trigonal [a, a, alpha, 0] -#For tetragonal [a, c, 90, 1] -#For cubic [a, a, 90, 2] -#It does not do data normalization!!! -#Be careful! Here cubic has label 2 instead of 0!!! + +# We currently handle cubic, trigonal and tetragonal sym, and we handle them separately here, and only solve the y-axis (parameter) +# We create Y-data with size n*4 and save to file +# For trigonal [a, a, alpha, 0] +# For tetragonal [a, c, 90, 1] +# For cubic [a, a, 90, 2] +# It does not do data normalization!!! +# Be careful! Here cubic has label 2 instead of 0!!! def main(): - if(len(sys.argv) != 4): + if len(sys.argv) != 4: print(sys.argv) - sys.stderr.write("Usage: python merge_preprocess_hdf5.py dir_path sym_class num_rank") + sys.stderr.write( + "Usage: python merge_preprocess_hdf5.py dir_path sym_class num_rank" + ) sys.exit(0) - dir_path = sys.argv[1] + dir_path = sys.argv[1] sym_class = str(sys.argv[2]) - num_rank = int(sys.argv[3]) - print("dir_path = {}\nsym_class = {}\nnum_rank = {}".format(dir_path, sym_class, num_rank)) + num_rank = int(sys.argv[3]) + print(f"dir_path = {dir_path}\nsym_class = {sym_class}\nnum_rank = {num_rank}") histograms = [] parameters = [] @@ -25,7 +29,7 @@ def main(): if sym_class == "trigonal": filename_prefix = "trigonal_1522004_trigonal" elif sym_class == "tetragonal": - filename_prefix = "tetragonal_1531431_tetragonal" + filename_prefix = "tetragonal_1531431_tetragonal" elif sym_class == "cubic": filename_prefix = "cubic_1001460_cubic" else: @@ -33,8 +37,10 @@ def main(): sys.exit(0) for rank in range(num_rank): - filename = "".join([dir_path, "/", filename_prefix, "_part", str(rank), ".hdf5"]) - with h5py.File(filename, 'r') as f: + filename = "".join( + [dir_path, "/", filename_prefix, "_part", str(rank), ".hdf5"] + ) + with h5py.File(filename, "r") as f: histograms.append(f["histograms"][:]) parameters.append(f["parameters"][:]) @@ -42,27 +48,34 @@ def main(): all_parameters = np.concatenate(parameters, axis=0) if sym_class == "trigonal": - parameters_a = all_parameters[:,0].reshape(-1, 1) + parameters_a = all_parameters[:, 0].reshape(-1, 1) class_label = np.array([0]) class_label = np.tile(class_label, (all_parameters.shape[0], 1)) - all_parameters = np.concatenate([all_parameters, parameters_a, class_label], axis=1) - all_parameters[:,[1,2]] = all_parameters[:,[2,1]] + all_parameters = np.concatenate( + [all_parameters, parameters_a, class_label], axis=1 + ) + all_parameters[:, [1, 2]] = all_parameters[:, [2, 1]] elif sym_class == "tetragonal": parameters_alpha = np.ones((all_parameters.shape[0], 1)) * 90.0 class_label = np.array([1]) class_label = np.tile(class_label, (all_parameters.shape[0], 1)) - all_parameters = np.concatenate([all_parameters, parameters_alpha, class_label], axis=1) + all_parameters = np.concatenate( + [all_parameters, parameters_alpha, class_label], axis=1 + ) elif sym_class == "cubic": - parameters_a = all_parameters[:,0].reshape(-1, 1) + parameters_a = all_parameters[:, 0].reshape(-1, 1) parameters_alpha = np.ones((all_parameters.shape[0], 1)) * 90.0 class_label = np.array([2]) class_label = np.tile(class_label, (all_parameters.shape[0], 1)) - all_parameters = np.concatenate([parameters_a, parameters_a, parameters_alpha, class_label], axis=1) + all_parameters = np.concatenate( + [parameters_a, parameters_a, parameters_alpha, class_label], axis=1 + ) newfile = "".join([dir_path, "/", filename_prefix, ".hdf5"]) - with h5py.File(newfile, 'w') as f: + with h5py.File(newfile, "w") as f: f.create_dataset("histograms", data=all_histograms) f.create_dataset("parameters", data=all_parameters) -if __name__ == '__main__': + +if __name__ == "__main__": main() diff --git a/examples/use_cases/neutron-scattering/scripts/model.py b/examples/use_cases/neutron-scattering/scripts/model.py index 4c0b1961..01e20ce4 100644 --- a/examples/use_cases/neutron-scattering/scripts/model.py +++ b/examples/use_cases/neutron-scattering/scripts/model.py @@ -1,34 +1,42 @@ import torch + class FullModel(torch.nn.Module): - def __init__(self, len_input, num_hidden, num_output, - conv1=(16, 3, 1), - pool1=(2, 2), - conv2=(32, 4, 2), - pool2=(2, 2), - fc1=256, - num_classes=3): + def __init__( + self, + len_input, + num_hidden, + num_output, + conv1=(16, 3, 1), + pool1=(2, 2), + conv2=(32, 4, 2), + pool2=(2, 2), + fc1=256, + num_classes=3, + ): super(FullModel, self).__init__() - + n = len_input # In-channels, Out-channels, Kernel_size, stride ... self.conv1 = torch.nn.Conv1d(1, conv1[0], conv1[1], stride=conv1[2]) n = (n - conv1[1]) // conv1[2] + 1 - self.pool1 = torch.nn.MaxPool1d(pool1[0], stride=pool1[1] ) + self.pool1 = torch.nn.MaxPool1d(pool1[0], stride=pool1[1]) n = (n - pool1[0]) // pool1[1] + 1 - + self.conv2 = torch.nn.Conv1d(conv1[0], conv2[0], conv2[1], stride=conv2[2]) n = (n - conv2[1]) // conv2[2] + 1 - - self.pool2 = torch.nn.MaxPool1d(pool2[0], stride=pool2[1] ) + + self.pool2 = torch.nn.MaxPool1d(pool2[0], stride=pool2[1]) n = (n - pool2[0]) // pool2[1] + 1 self.relu = torch.nn.LeakyReLU(0.1) - self.features = torch.nn.Sequential( self.conv1, self.relu, self.pool1, self.conv2, self.relu, self.pool2 ) - self.fc1 = torch.nn.Linear(n*conv2[0], fc1) - self.fc2 = torch.nn.Linear(fc1, num_classes) - self.regression_layer=torch.nn.Linear(num_hidden, num_output) + self.features = torch.nn.Sequential( + self.conv1, self.relu, self.pool1, self.conv2, self.relu, self.pool2 + ) + self.fc1 = torch.nn.Linear(n * conv2[0], fc1) + self.fc2 = torch.nn.Linear(fc1, num_classes) + self.regression_layer = torch.nn.Linear(num_hidden, num_output) def forward(self, x): x = self.features(x) diff --git a/examples/use_cases/neutron-scattering/scripts/prepare_data_dir_pm.py b/examples/use_cases/neutron-scattering/scripts/prepare_data_dir_pm.py index be7cb834..04789925 100644 --- a/examples/use_cases/neutron-scattering/scripts/prepare_data_dir_pm.py +++ b/examples/use_cases/neutron-scattering/scripts/prepare_data_dir_pm.py @@ -1,19 +1,32 @@ -import os -import sys import argparse +import os -parser = argparse.ArgumentParser(description='Prepare for data dir with a given seed') -parser.add_argument('--seed', type=int, required=True, help='An integer seed') +parser = argparse.ArgumentParser(description="Prepare for data dir with a given seed") +parser.add_argument("--seed", type=int, required=True, help="An integer seed") args = parser.parse_args() seed = args.seed -code_path = f'{os.getcwd()}' -root_dir = f'{code_path}/data/' -cif_file = f'{code_path}/cif_file_in/' +code_path = f"{os.getcwd()}" +root_dir = f"{code_path}/data/" +cif_file = f"{code_path}/cif_file_in/" -dir_list = ["base", "validation", "test", "study", "stream_phase_1", "stream_phase_2", "AL_phase_1", "AL_phase_2", "AL_phase_3"] -config_list = ["config_1001460_cubic.txt", "config_1522004_trigonal.txt", "config_1531431_tetragonal.txt"] +dir_list = [ + "base", + "validation", + "test", + "study", + "stream_phase_1", + "stream_phase_2", + "AL_phase_1", + "AL_phase_2", + "AL_phase_3", +] +config_list = [ + "config_1001460_cubic.txt", + "config_1522004_trigonal.txt", + "config_1531431_tetragonal.txt", +] cubic_config = f"""[Global_Params] path_in = '{cif_file}' @@ -27,7 +40,7 @@ tmax = 18.919 tstep = 0.0009381""" -trigonal_config=f"""[Global_Params] +trigonal_config = f"""[Global_Params] path_in = '{cif_file}' symmetry = 'trigonal' name = 'LaMnO3' @@ -39,7 +52,7 @@ tmax = 18.919 tstep = 0.0009381""" -tetragonal_config=f"""[Global_Params] +tetragonal_config = f"""[Global_Params] path_in = '{cif_file}' symmetry = 'tetragonal' name = 'KNbO3' @@ -54,15 +67,15 @@ config_content = [cubic_config, trigonal_config, tetragonal_config] for dir_name in dir_list: - dir_name = root_dir + "seed_{}/".format(seed) + dir_name + dir_name = root_dir + f"seed_{seed}/" + dir_name os.makedirs(dir_name, exist_ok=True) config_path = os.path.join(dir_name, "config") os.makedirs(config_path, exist_ok=True) - data_path = os.path.join(dir_name, "data") + data_path = os.path.join(dir_name, "data") os.makedirs(data_path, exist_ok=True) for i, config_file in enumerate(config_list): config_file = os.path.join(config_path, config_file) - with open(config_file, 'w') as file: + with open(config_file, "w") as file: file.write(config_content[i].format(data_path)) diff --git a/examples/use_cases/neutron-scattering/scripts/preprocess_study.py b/examples/use_cases/neutron-scattering/scripts/preprocess_study.py index f96b00d2..1cc7029b 100644 --- a/examples/use_cases/neutron-scattering/scripts/preprocess_study.py +++ b/examples/use_cases/neutron-scattering/scripts/preprocess_study.py @@ -1,31 +1,40 @@ #!/usr/bin/env python -import io, os, sys import argparse -import numpy as np -import torch +import os +import torch import util - -parser = argparse.ArgumentParser(description='Exalearn_preprocess_study_set') -parser.add_argument('--data_dir', type=str, default='./', - help='root directory of base/test/study/AL subdir') +parser = argparse.ArgumentParser(description="Exalearn_preprocess_study_set") +parser.add_argument( + "--data_dir", + type=str, + default="./", + help="root directory of base/test/study/AL subdir", +) args = parser.parse_args() -study_cubic_file = os.path.join(args.data_dir, "study/data/cubic_1001460_cubic.hdf5") -study_trigonal_file = os.path.join(args.data_dir, "study/data/trigonal_1522004_trigonal.hdf5") -study_tetragonal_file = os.path.join(args.data_dir, "study/data/tetragonal_1531431_tetragonal.hdf5") - -x_study, y_study = util.create_numpy_data(study_cubic_file, study_trigonal_file, study_tetragonal_file) +study_cubic_file = os.path.join(args.data_dir, "study/data/cubic_1001460_cubic.hdf5") +study_trigonal_file = os.path.join( + args.data_dir, "study/data/trigonal_1522004_trigonal.hdf5" +) +study_tetragonal_file = os.path.join( + args.data_dir, "study/data/tetragonal_1531431_tetragonal.hdf5" +) + +x_study, y_study = util.create_numpy_data( + study_cubic_file, study_trigonal_file, study_tetragonal_file +) print("x_study.shape = ", x_study.shape) print("y_study.shape = ", y_study.shape) x_study_torch = torch.from_numpy(x_study).float() -x_study_torch = x_study_torch.reshape((x_study_torch.shape[0], 1, x_study_torch.shape[1])) +x_study_torch = x_study_torch.reshape( + (x_study_torch.shape[0], 1, x_study_torch.shape[1]) +) y_study_torch = torch.from_numpy(y_study).float() print("x_study_torch.shape = ", x_study_torch.shape) print("y_study_torch.shape = ", y_study_torch.shape) torch.save(x_study_torch, "x_study_torch.pt") - diff --git a/examples/use_cases/neutron-scattering/scripts/replacement_sim.py b/examples/use_cases/neutron-scattering/scripts/replacement_sim.py index d0acd91e..d7515586 100644 --- a/examples/use_cases/neutron-scattering/scripts/replacement_sim.py +++ b/examples/use_cases/neutron-scattering/scripts/replacement_sim.py @@ -1,13 +1,14 @@ #!/usr/bin/env python -import time import sys +import time + def main(): print("Arguments:", sys.argv) print("Sleeping for 10 seconds") time.sleep(10) print("Waking up") - -if __name__ == '__main__': - main() + +if __name__ == "__main__": + main() diff --git a/examples/use_cases/neutron-scattering/scripts/simulation_resample.py b/examples/use_cases/neutron-scattering/scripts/simulation_resample.py index 5da02f67..c2b86eab 100644 --- a/examples/use_cases/neutron-scattering/scripts/simulation_resample.py +++ b/examples/use_cases/neutron-scattering/scripts/simulation_resample.py @@ -1,27 +1,27 @@ #!/usr/bin/env python -import os,sys -sys.path.insert(0,os.path.expanduser("~/g2full/GSAS-II/GSASII/")) -import GSASIIscriptable as G2sc +import os +import sys -from mpi4py import MPI -import numpy as np +sys.path.insert(0, os.path.expanduser("~/g2full/GSAS-II/GSASII/")) import time -import h5py -from contextlib import redirect_stdout #for debug +import GSASIIscriptable as G2sc +import h5py +import numpy as np import sweep_utils as su +from mpi4py import MPI gpx = [] phase = [] def cubic_lattice(prm): - """ This function uses a 1D grid. + """This function uses a 1D grid. - Parameters for cubic lattice: - a = b = c - alpha = beta = gamma = 90 + Parameters for cubic lattice: + a = b = c + alpha = beta = gamma = 90 """ global gpx @@ -29,32 +29,31 @@ def cubic_lattice(prm): # Unit cell parameters # Lattice - phase['General']['Cell'][1] = prm[0] # a - phase['General']['Cell'][2] = prm[0] # b - phase['General']['Cell'][3] = prm[0] # c + phase["General"]["Cell"][1] = prm[0] # a + phase["General"]["Cell"][2] = prm[0] # b + phase["General"]["Cell"][3] = prm[0] # c # Angles - phase['General']['Cell'][4] = 90 # alpha - phase['General']['Cell'][5] = 90 # beta - phase['General']['Cell'][6] = 90 # gamma + phase["General"]["Cell"][4] = 90 # alpha + phase["General"]["Cell"][5] = 90 # beta + phase["General"]["Cell"][6] = 90 # gamma # Compute simulation - gpx.data['Controls']['data']['max cyc']=0 + gpx.data["Controls"]["data"]["max cyc"] = 0 gpx.do_refinements([{}]) - x = gpx.histogram(0).getdata('x') - y = gpx.histogram(0).getdata('ycalc') + x = gpx.histogram(0).getdata("x") + y = gpx.histogram(0).getdata("ycalc") return x, y - def trigonal_lattice(prm): - """ This function uses a 2D grid. + """This function uses a 2D grid. - Parameters for trigonal lattice: - a = b = c - alpha = beta = gamma != 90 + Parameters for trigonal lattice: + a = b = c + alpha = beta = gamma != 90 """ global gpx @@ -62,32 +61,31 @@ def trigonal_lattice(prm): # Unit cell parameters # Lattice - phase['General']['Cell'][1] = prm[0] # a - phase['General']['Cell'][2] = prm[0] # b - phase['General']['Cell'][3] = prm[0] # c + phase["General"]["Cell"][1] = prm[0] # a + phase["General"]["Cell"][2] = prm[0] # b + phase["General"]["Cell"][3] = prm[0] # c # Angles - phase['General']['Cell'][4] = prm[1] # alpha - phase['General']['Cell'][5] = prm[1] # beta - phase['General']['Cell'][6] = prm[1] # gamma + phase["General"]["Cell"][4] = prm[1] # alpha + phase["General"]["Cell"][5] = prm[1] # beta + phase["General"]["Cell"][6] = prm[1] # gamma # Compute simulation - gpx.data['Controls']['data']['max cyc']=0 + gpx.data["Controls"]["data"]["max cyc"] = 0 gpx.do_refinements([{}]) - x = gpx.histogram(0).getdata('x') - y = gpx.histogram(0).getdata('ycalc') + x = gpx.histogram(0).getdata("x") + y = gpx.histogram(0).getdata("ycalc") return x, y - def tetragonal_lattice(prm): - """ This function uses a 2D grid. + """This function uses a 2D grid. - Parameters for tetragonal lattice: - a = b != c - alpha = beta = gamma = 90 + Parameters for tetragonal lattice: + a = b != c + alpha = beta = gamma = 90 """ global gpx @@ -96,21 +94,21 @@ def tetragonal_lattice(prm): # Unit cell parameters # Lattice # c should be differe - phase['General']['Cell'][1] = prm[0] # a - phase['General']['Cell'][2] = prm[0] # b - phase['General']['Cell'][3] = prm[1] # c + phase["General"]["Cell"][1] = prm[0] # a + phase["General"]["Cell"][2] = prm[0] # b + phase["General"]["Cell"][3] = prm[1] # c # Angles - phase['General']['Cell'][4] = 90 # alpha - phase['General']['Cell'][5] = 90 # beta - phase['General']['Cell'][6] = 90 # gamma + phase["General"]["Cell"][4] = 90 # alpha + phase["General"]["Cell"][5] = 90 # beta + phase["General"]["Cell"][6] = 90 # gamma # Compute simulation - gpx.data['Controls']['data']['max cyc']=0 + gpx.data["Controls"]["data"]["max cyc"] = 0 gpx.do_refinements([{}]) - x = gpx.histogram(0).getdata('x') - y = gpx.histogram(0).getdata('ycalc') + x = gpx.histogram(0).getdata("x") + y = gpx.histogram(0).getdata("ycalc") return x, y @@ -119,164 +117,232 @@ def _generate_random_uniform(sz, low, high): random_numbers = np.random.uniform(low, high, sz) return random_numbers.reshape(-1, 1) + def generate_cubic_random_sample(sz, a_min, a_max): a_arr = _generate_random_uniform(sz, a_min, a_max) return a_arr + def generate_trigonal_random_sample(sz, a_min, a_max, alpha_min, alpha_max): a_arr = _generate_random_uniform(sz, a_min, a_max) alpha_arr = _generate_random_uniform(sz, alpha_min, alpha_max) return np.column_stack((a_arr, alpha_arr)) + def generate_tetragonal_random_sample(sz, a_min, a_max, c_min, c_max): a_arr = _generate_random_uniform(sz, a_min, a_max) c_arr = _generate_random_uniform(sz, c_min, c_max) return np.column_stack((a_arr, c_arr)) -#Here the input y_study is mixed which has [a, c, alpha, class] -#Here freq is also mixed based on y_study -#Here samples we generate will be separated for better comm with _remove_out_of_box -def generate_gaussian_sample_separate(y_study, freq, std_dev_cubic, std_dev_trigonal, std_dev_tetragonal, do_print = False): + +# Here the input y_study is mixed which has [a, c, alpha, class] +# Here freq is also mixed based on y_study +# Here samples we generate will be separated for better comm with _remove_out_of_box +def generate_gaussian_sample_separate( + y_study, freq, std_dev_cubic, std_dev_trigonal, std_dev_tetragonal, do_print=False +): samples_cubic = [] samples_trigonal = [] samples_tetragonal = [] for i in range(len(freq)): if freq[i] > 0: if do_print: - print("i = ", i, " freq[i] = ", freq[i], " y_study[i] = ", y_study[i], '\n') - if y_study[i, 3] == 0: #trigonal + print( + "i = ", + i, + " freq[i] = ", + freq[i], + " y_study[i] = ", + y_study[i], + "\n", + ) + if y_study[i, 3] == 0: # trigonal for _ in range(freq[i]): - sample = np.random.normal(loc=[y_study[i, 0], y_study[i, 2]], scale=std_dev_trigonal) + sample = np.random.normal( + loc=[y_study[i, 0], y_study[i, 2]], scale=std_dev_trigonal + ) if do_print: - print("trigonal sample = ", sample, '\n') + print("trigonal sample = ", sample, "\n") samples_trigonal.append(sample) - elif y_study[i, 3] == 1: #tetragonal + elif y_study[i, 3] == 1: # tetragonal for _ in range(freq[i]): - sample = np.random.normal(loc=[y_study[i, 0], y_study[i, 1]], scale=std_dev_tetragonal) + sample = np.random.normal( + loc=[y_study[i, 0], y_study[i, 1]], scale=std_dev_tetragonal + ) if do_print: - print("tetragonal sample = ", sample, '\n') + print("tetragonal sample = ", sample, "\n") samples_tetragonal.append(sample) - elif y_study[i, 3] == 2: #cubic + elif y_study[i, 3] == 2: # cubic for _ in range(freq[i]): sample = np.random.normal(loc=[y_study[i, 0]], scale=std_dev_cubic) if do_print: - print("cubic sample = ", sample, '\n') + print("cubic sample = ", sample, "\n") samples_cubic.append(sample) - return np.vstack(samples_cubic), np.vstack(samples_trigonal), np.vstack(samples_tetragonal) + return ( + np.vstack(samples_cubic), + np.vstack(samples_trigonal), + np.vstack(samples_tetragonal), + ) + def _remove_out_of_box(sym, sample_in, bounding_box, min_diff): - assert bounding_box.shape[1] == 2 #[[begin1, end1], [begin2, end2], [begin3, end3], ...] + assert ( + bounding_box.shape[1] == 2 + ) # [[begin1, end1], [begin2, end2], [begin3, end3], ...] if sym == "cubic": assert sample_in.shape[1] == 1 - assert bounding_box.shape[0] == 1 #on dim a - idx = (sample_in[:,0] >= bounding_box[0,0]) & (sample_in[:,0] <= bounding_box[0,1]) + assert bounding_box.shape[0] == 1 # on dim a + idx = (sample_in[:, 0] >= bounding_box[0, 0]) & ( + sample_in[:, 0] <= bounding_box[0, 1] + ) sample_in = sample_in[idx] elif sym == "trigonal": assert sample_in.shape[1] == 2 - assert bounding_box.shape[0] == 2 #on dim a, angle - idx = (sample_in[:,0] >= bounding_box[0,0]) & (sample_in[:,0] <= bounding_box[0,1]) & \ - (sample_in[:,1] >= bounding_box[1,0]) & (sample_in[:,1] <= bounding_box[1,1]) & \ - (abs(sample_in[:,1] - 90) > min_diff) #angle different from 90 degree + assert bounding_box.shape[0] == 2 # on dim a, angle + idx = ( + (sample_in[:, 0] >= bounding_box[0, 0]) + & (sample_in[:, 0] <= bounding_box[0, 1]) + & (sample_in[:, 1] >= bounding_box[1, 0]) + & (sample_in[:, 1] <= bounding_box[1, 1]) + & (abs(sample_in[:, 1] - 90) > min_diff) + ) # angle different from 90 degree sample_in = sample_in[idx] elif sym == "tetragonal": assert sample_in.shape[1] == 2 - assert bounding_box.shape[0] == 2 #on dim a, c - idx = (sample_in[:,0] >= bounding_box[0,0]) & (sample_in[:,0] <= bounding_box[0,1]) & \ - (sample_in[:,1] >= bounding_box[1,0]) & (sample_in[:,1] <= bounding_box[1,1]) & \ - (abs(sample_in[:,0] - sample_in[:,1]) > min_diff) #two edge should be different + assert bounding_box.shape[0] == 2 # on dim a, c + idx = ( + (sample_in[:, 0] >= bounding_box[0, 0]) + & (sample_in[:, 0] <= bounding_box[0, 1]) + & (sample_in[:, 1] >= bounding_box[1, 0]) + & (sample_in[:, 1] <= bounding_box[1, 1]) + & (abs(sample_in[:, 0] - sample_in[:, 1]) > min_diff) + ) # two edge should be different sample_in = sample_in[idx] else: - exit("Error! Unrecognized sym argument = {} in _remove_out_of_box function call".format(sym)) + exit( + f"Error! Unrecognized sym argument = {sym} in _remove_out_of_box function call" + ) return sample_in -#All rank will execute this function -#Different from other simulation_xxx, it will first generate exactly the same sample list -#Then different rank will simulate different part of it -#Because of that all rank use global seed! -def create_all_samples(rank, size, seed_g, - y_study, freq, - cubic_bounding_box, - trigonal_bounding_box, alpha_diff_trigonal, - tetragonal_bounding_box, ac_diff_tetragonal): - np.random.seed(seed_g) - do_print = (rank == 0) - print("IMPORTANT: In resample, rank = {}, seed for create_all_samples = {}!!!".format(rank, seed_g)) - sample_cubic, sample_trigonal, sample_tetragonal = generate_gaussian_sample_separate(y_study, freq, - std_dev_cubic = [0.0001], - std_dev_trigonal = [0.002, 0.3], - std_dev_tetragonal = [0.002, 0.002], - do_print = do_print) - sample_cubic = _remove_out_of_box("cubic", sample_cubic, cubic_bounding_box, None) - sample_trigonal = _remove_out_of_box("trigonal", sample_trigonal, trigonal_bounding_box, alpha_diff_trigonal) - sample_tetragonal = _remove_out_of_box("tetragonal", sample_tetragonal, tetragonal_bounding_box, ac_diff_tetragonal) +# All rank will execute this function +# Different from other simulation_xxx, it will first generate exactly the same sample list +# Then different rank will simulate different part of it +# Because of that all rank use global seed! +def create_all_samples( + rank, + size, + seed_g, + y_study, + freq, + cubic_bounding_box, + trigonal_bounding_box, + alpha_diff_trigonal, + tetragonal_bounding_box, + ac_diff_tetragonal, +): + np.random.seed(seed_g) + do_print = rank == 0 + print( + f"IMPORTANT: In resample, rank = {rank}, seed for create_all_samples = {seed_g}!!!" + ) + + sample_cubic, sample_trigonal, sample_tetragonal = ( + generate_gaussian_sample_separate( + y_study, + freq, + std_dev_cubic=[0.0001], + std_dev_trigonal=[0.002, 0.3], + std_dev_tetragonal=[0.002, 0.002], + do_print=do_print, + ) + ) + sample_cubic = _remove_out_of_box("cubic", sample_cubic, cubic_bounding_box, None) + sample_trigonal = _remove_out_of_box( + "trigonal", sample_trigonal, trigonal_bounding_box, alpha_diff_trigonal + ) + sample_tetragonal = _remove_out_of_box( + "tetragonal", sample_tetragonal, tetragonal_bounding_box, ac_diff_tetragonal + ) + + return ( + sample_cubic[rank::size][:], + sample_trigonal[rank::size][:], + sample_tetragonal[rank::size][:], + ) - return sample_cubic[rank::size][:], sample_trigonal[rank::size][:], sample_tetragonal[rank::size][:] def _sim_impl(rank, size, sym, conffile_name, sample): start = time.time() gParameters = su.read_config_file(conffile_name) # Create project - name = gParameters['name'] +'_rank' + str(rank) - path_in = gParameters['path_in'] - path_out = gParameters['path_out'] - name_out = gParameters['name_out'] + name = gParameters["name"] + "_rank" + str(rank) + path_in = gParameters["path_in"] + path_out = gParameters["path_out"] + name_out = gParameters["name_out"] global gpx - gpx = G2sc.G2Project(newgpx=path_out+name+'.gpx') - + gpx = G2sc.G2Project(newgpx=path_out + name + ".gpx") + # Add phase: Requires CIF file - cif = path_in + gParameters['cif'] + cif = path_in + gParameters["cif"] global phase - phase = gpx.add_phase(cif,phasename=name,fmthint='CIF') - + phase = gpx.add_phase(cif, phasename=name, fmthint="CIF") + # Get instrument file specification - instprm = path_in + gParameters['instprm'] + instprm = path_in + gParameters["instprm"] # Histogram range - Tmin = gParameters['tmin'] - Tmax = gParameters['tmax'] - Tstep = gParameters['tstep'] - hist = gpx.add_simulated_powder_histogram(name+'TOFsimulation',instprm,Tmin,Tmax,Tstep,phases=gpx.phases()) - hist.SampleParameters['Scale'][0] = 1000. + Tmin = gParameters["tmin"] + Tmax = gParameters["tmax"] + Tstep = gParameters["tstep"] + hist = gpx.add_simulated_powder_histogram( + name + "TOFsimulation", instprm, Tmin, Tmax, Tstep, phases=gpx.phases() + ) + hist.SampleParameters["Scale"][0] = 1000.0 # Set to no-background - hist['Background'][0][3]=0.0 - - symmetry = gParameters['symmetry'] - assert symmetry == sym, "symmetry = {} and sym = {}".format(symmetry, sym) - + hist["Background"][0][3] = 0.0 + + symmetry = gParameters["symmetry"] + assert symmetry == sym, f"symmetry = {symmetry} and sym = {sym}" + # Configure sweep according to symmetry - if symmetry == 'cubic': + if symmetry == "cubic": sweepf_ = cubic_lattice - elif symmetry == 'trigonal': + elif symmetry == "trigonal": sweepf_ = trigonal_lattice - elif symmetry == 'tetragonal': + elif symmetry == "tetragonal": sweepf_ = tetragonal_lattice else: - exit("Do not recognize symmetry of {}".format(symmetry)) - + exit(f"Do not recognize symmetry of {symmetry}") + # Distribute computation - nsim, histosz = su.grid_sample(rank, size, sweepf_, sample, path_out, name_out + '_' + symmetry) + nsim, histosz = su.grid_sample( + rank, size, sweepf_, sample, path_out, name_out + "_" + symmetry + ) end = time.time() - print('----------------------------------------------------------') - print("Rank = {}, Number of simulations ({}): {}, size of histogram: {}, cost {} seconds".format(rank, symmetry, nsim, histosz, end-start)) + print("----------------------------------------------------------") + print( + f"Rank = {rank}, Number of simulations ({symmetry}): {nsim}, size of histogram: {histosz}, cost {end - start} seconds" + ) def main(): - start = time.time() - if ( len ( sys.argv ) != 8 ) : + if len(sys.argv) != 8: print(sys.argv) - sys.stderr.write("Usage: python simulation_resample.py " - "global_seed conf_name_cubic cubic_studyset " - "conf_name_trigonal trigonal_studyset " - "conf_name_tetragonal tetragonal_studyset\n") + sys.stderr.write( + "Usage: python simulation_resample.py " + "global_seed conf_name_cubic cubic_studyset " + "conf_name_trigonal trigonal_studyset " + "conf_name_tetragonal tetragonal_studyset\n" + ) sys.exit(0) comm = MPI.COMM_WORLD size = comm.Get_size() rank = comm.Get_rank() - print("Rank = {} out of size = {}".format(rank, size)) + print(f"Rank = {rank} out of size = {size}") global_seed = int(sys.argv[1]) conf_name_cubic = sys.argv[2] @@ -301,33 +367,42 @@ def main(): alpha_diff_trigonal = 0.2 ac_diff_tetragonal = 0.001 - with h5py.File(cubic_studyset, 'r') as f: - dparams = f['parameters'] + with h5py.File(cubic_studyset, "r") as f: + dparams = f["parameters"] y_study_cubic = dparams[:] y_shape_cubic = y_study_cubic.shape print("y_shape_cubic = ", y_shape_cubic) - with h5py.File(trigonal_studyset, 'r') as f: - dparams = f['parameters'] + with h5py.File(trigonal_studyset, "r") as f: + dparams = f["parameters"] y_study_trigonal = dparams[:] y_shape_trigonal = y_study_trigonal.shape print("y_shape_trigonal = ", y_shape_trigonal) - with h5py.File(tetragonal_studyset, 'r') as f: - dparams = f['parameters'] + with h5py.File(tetragonal_studyset, "r") as f: + dparams = f["parameters"] y_study_tetragonal = dparams[:] y_shape_tetragonal = y_study_tetragonal.shape print("y_shape_tetragonal = ", y_shape_tetragonal) - y_study = np.concatenate([y_study_cubic, y_study_trigonal, y_study_tetragonal], axis=0) - - freq = np.load('AL-freq.npy') - - sample_cubic, sample_trigonal, sample_tetragonal = create_all_samples(rank, size, global_seed, - y_study, freq, - cubic_bounding_box, - trigonal_bounding_box, alpha_diff_trigonal, - tetragonal_bounding_box, ac_diff_tetragonal) + y_study = np.concatenate( + [y_study_cubic, y_study_trigonal, y_study_tetragonal], axis=0 + ) + + freq = np.load("AL-freq.npy") + + sample_cubic, sample_trigonal, sample_tetragonal = create_all_samples( + rank, + size, + global_seed, + y_study, + freq, + cubic_bounding_box, + trigonal_bounding_box, + alpha_diff_trigonal, + tetragonal_bounding_box, + ac_diff_tetragonal, + ) print(sample_cubic) print(sample_trigonal) print(sample_tetragonal) @@ -337,6 +412,5 @@ def main(): _sim_impl(rank, size, "tetragonal", conf_name_tetragonal, sample_tetragonal) -if __name__ == '__main__': +if __name__ == "__main__": main() - diff --git a/examples/use_cases/neutron-scattering/scripts/simulation_resample_prepare.py b/examples/use_cases/neutron-scattering/scripts/simulation_resample_prepare.py index f2842bcf..9c4d8982 100644 --- a/examples/use_cases/neutron-scattering/scripts/simulation_resample_prepare.py +++ b/examples/use_cases/neutron-scattering/scripts/simulation_resample_prepare.py @@ -1,129 +1,192 @@ #!/usr/bin/env python -import os,sys -sys.path.insert(0,os.path.expanduser("~/g2full/GSAS-II/GSASII/")) -import GSASIIscriptable as G2sc +import os +import sys + +sys.path.insert(0, os.path.expanduser("~/g2full/GSAS-II/GSASII/")) -from mpi4py import MPI -import numpy as np import time + import h5py -from contextlib import redirect_stdout #for debug +import numpy as np +from mpi4py import MPI def _generate_random_uniform(sz, low, high): random_numbers = np.random.uniform(low, high, sz) return random_numbers.reshape(-1, 1) + def generate_cubic_random_sample(sz, a_min, a_max): a_arr = _generate_random_uniform(sz, a_min, a_max) return a_arr + def generate_trigonal_random_sample(sz, a_min, a_max, alpha_min, alpha_max): a_arr = _generate_random_uniform(sz, a_min, a_max) alpha_arr = _generate_random_uniform(sz, alpha_min, alpha_max) return np.column_stack((a_arr, alpha_arr)) + def generate_tetragonal_random_sample(sz, a_min, a_max, c_min, c_max): a_arr = _generate_random_uniform(sz, a_min, a_max) c_arr = _generate_random_uniform(sz, c_min, c_max) return np.column_stack((a_arr, c_arr)) -#Here the input y_study is mixed which has [a, c, alpha, class] -#Here freq is also mixed based on y_study -#Here samples we generate will be separated for better comm with _remove_out_of_box -def generate_gaussian_sample_separate(y_study, freq, std_dev_cubic, std_dev_trigonal, std_dev_tetragonal, do_print = False): + +# Here the input y_study is mixed which has [a, c, alpha, class] +# Here freq is also mixed based on y_study +# Here samples we generate will be separated for better comm with _remove_out_of_box +def generate_gaussian_sample_separate( + y_study, freq, std_dev_cubic, std_dev_trigonal, std_dev_tetragonal, do_print=False +): samples_cubic = [] samples_trigonal = [] samples_tetragonal = [] for i in range(len(freq)): if freq[i] > 0: if do_print: - print("i = ", i, " freq[i] = ", freq[i], " y_study[i] = ", y_study[i], '\n') - if y_study[i, 3] == 0: #trigonal + print( + "i = ", + i, + " freq[i] = ", + freq[i], + " y_study[i] = ", + y_study[i], + "\n", + ) + if y_study[i, 3] == 0: # trigonal for _ in range(freq[i]): - sample = np.random.normal(loc=[y_study[i, 0], y_study[i, 2]], scale=std_dev_trigonal) + sample = np.random.normal( + loc=[y_study[i, 0], y_study[i, 2]], scale=std_dev_trigonal + ) if do_print: - print("trigonal sample = ", sample, '\n') + print("trigonal sample = ", sample, "\n") samples_trigonal.append(sample) - elif y_study[i, 3] == 1: #tetragonal + elif y_study[i, 3] == 1: # tetragonal for _ in range(freq[i]): - sample = np.random.normal(loc=[y_study[i, 0], y_study[i, 1]], scale=std_dev_tetragonal) + sample = np.random.normal( + loc=[y_study[i, 0], y_study[i, 1]], scale=std_dev_tetragonal + ) if do_print: - print("tetragonal sample = ", sample, '\n') + print("tetragonal sample = ", sample, "\n") samples_tetragonal.append(sample) - elif y_study[i, 3] == 2: #cubic + elif y_study[i, 3] == 2: # cubic for _ in range(freq[i]): sample = np.random.normal(loc=[y_study[i, 0]], scale=std_dev_cubic) if do_print: - print("cubic sample = ", sample, '\n') + print("cubic sample = ", sample, "\n") samples_cubic.append(sample) - return np.vstack(samples_cubic), np.vstack(samples_trigonal), np.vstack(samples_tetragonal) + return ( + np.vstack(samples_cubic), + np.vstack(samples_trigonal), + np.vstack(samples_tetragonal), + ) + def _remove_out_of_box(sym, sample_in, bounding_box, min_diff): - assert bounding_box.shape[1] == 2 #[[begin1, end1], [begin2, end2], [begin3, end3], ...] + assert ( + bounding_box.shape[1] == 2 + ) # [[begin1, end1], [begin2, end2], [begin3, end3], ...] if sym == "cubic": assert sample_in.shape[1] == 1 - assert bounding_box.shape[0] == 1 #on dim a - idx = (sample_in[:,0] >= bounding_box[0,0]) & (sample_in[:,0] <= bounding_box[0,1]) + assert bounding_box.shape[0] == 1 # on dim a + idx = (sample_in[:, 0] >= bounding_box[0, 0]) & ( + sample_in[:, 0] <= bounding_box[0, 1] + ) sample_in = sample_in[idx] elif sym == "trigonal": assert sample_in.shape[1] == 2 - assert bounding_box.shape[0] == 2 #on dim a, angle - idx = (sample_in[:,0] >= bounding_box[0,0]) & (sample_in[:,0] <= bounding_box[0,1]) & \ - (sample_in[:,1] >= bounding_box[1,0]) & (sample_in[:,1] <= bounding_box[1,1]) & \ - (abs(sample_in[:,1] - 90) > min_diff) #angle different from 90 degree + assert bounding_box.shape[0] == 2 # on dim a, angle + idx = ( + (sample_in[:, 0] >= bounding_box[0, 0]) + & (sample_in[:, 0] <= bounding_box[0, 1]) + & (sample_in[:, 1] >= bounding_box[1, 0]) + & (sample_in[:, 1] <= bounding_box[1, 1]) + & (abs(sample_in[:, 1] - 90) > min_diff) + ) # angle different from 90 degree sample_in = sample_in[idx] elif sym == "tetragonal": assert sample_in.shape[1] == 2 - assert bounding_box.shape[0] == 2 #on dim a, c - idx = (sample_in[:,0] >= bounding_box[0,0]) & (sample_in[:,0] <= bounding_box[0,1]) & \ - (sample_in[:,1] >= bounding_box[1,0]) & (sample_in[:,1] <= bounding_box[1,1]) & \ - (abs(sample_in[:,0] - sample_in[:,1]) > min_diff) #two edge should be different + assert bounding_box.shape[0] == 2 # on dim a, c + idx = ( + (sample_in[:, 0] >= bounding_box[0, 0]) + & (sample_in[:, 0] <= bounding_box[0, 1]) + & (sample_in[:, 1] >= bounding_box[1, 0]) + & (sample_in[:, 1] <= bounding_box[1, 1]) + & (abs(sample_in[:, 0] - sample_in[:, 1]) > min_diff) + ) # two edge should be different sample_in = sample_in[idx] else: - exit("Error! Unrecognized sym argument = {} in _remove_out_of_box function call".format(sym)) + exit( + f"Error! Unrecognized sym argument = {sym} in _remove_out_of_box function call" + ) return sample_in -#All rank will execute this function -#Different from other simulation_xxx, it will first generate exactly the same sample list -#Then different rank will simulate different part of it -#Because of that all rank use global seed! -def create_all_samples(rank, size, seed_g, - y_study, freq, - cubic_bounding_box, - trigonal_bounding_box, alpha_diff_trigonal, - tetragonal_bounding_box, ac_diff_tetragonal): - np.random.seed(seed_g) - do_print = (rank == 0) - print("IMPORTANT: In resample, rank = {}, seed for create_all_samples = {}!!!".format(rank, seed_g)) - sample_cubic, sample_trigonal, sample_tetragonal = generate_gaussian_sample_separate(y_study, freq, - std_dev_cubic = [0.0001], - std_dev_trigonal = [0.002, 0.3], - std_dev_tetragonal = [0.002, 0.002], - do_print = do_print) - sample_cubic = _remove_out_of_box("cubic", sample_cubic, cubic_bounding_box, None) - sample_trigonal = _remove_out_of_box("trigonal", sample_trigonal, trigonal_bounding_box, alpha_diff_trigonal) - sample_tetragonal = _remove_out_of_box("tetragonal", sample_tetragonal, tetragonal_bounding_box, ac_diff_tetragonal) +# All rank will execute this function +# Different from other simulation_xxx, it will first generate exactly the same sample list +# Then different rank will simulate different part of it +# Because of that all rank use global seed! +def create_all_samples( + rank, + size, + seed_g, + y_study, + freq, + cubic_bounding_box, + trigonal_bounding_box, + alpha_diff_trigonal, + tetragonal_bounding_box, + ac_diff_tetragonal, +): + np.random.seed(seed_g) + do_print = rank == 0 + print( + f"IMPORTANT: In resample, rank = {rank}, seed for create_all_samples = {seed_g}!!!" + ) + + sample_cubic, sample_trigonal, sample_tetragonal = ( + generate_gaussian_sample_separate( + y_study, + freq, + std_dev_cubic=[0.0001], + std_dev_trigonal=[0.002, 0.3], + std_dev_tetragonal=[0.002, 0.002], + do_print=do_print, + ) + ) + sample_cubic = _remove_out_of_box("cubic", sample_cubic, cubic_bounding_box, None) + sample_trigonal = _remove_out_of_box( + "trigonal", sample_trigonal, trigonal_bounding_box, alpha_diff_trigonal + ) + sample_tetragonal = _remove_out_of_box( + "tetragonal", sample_tetragonal, tetragonal_bounding_box, ac_diff_tetragonal + ) + + return ( + sample_cubic[rank::size][:], + sample_trigonal[rank::size][:], + sample_tetragonal[rank::size][:], + ) - return sample_cubic[rank::size][:], sample_trigonal[rank::size][:], sample_tetragonal[rank::size][:] def main(): - start = time.time() - if ( len ( sys.argv ) != 5 ) : + if len(sys.argv) != 5: print(sys.argv) - sys.stderr.write("Usage: python simulation_resample.py " - "global_seed cubic_studyset " - "trigonal_studyset tetragonal_studyset\n") + sys.stderr.write( + "Usage: python simulation_resample.py " + "global_seed cubic_studyset " + "trigonal_studyset tetragonal_studyset\n" + ) sys.exit(0) comm = MPI.COMM_WORLD size = comm.Get_size() rank = comm.Get_rank() - print("Rank = {} out of size = {}".format(rank, size)) + print(f"Rank = {rank} out of size = {size}") global_seed = int(sys.argv[1]) cubic_studyset = str(sys.argv[2]) @@ -145,38 +208,47 @@ def main(): alpha_diff_trigonal = 0.2 ac_diff_tetragonal = 0.001 - with h5py.File(cubic_studyset, 'r') as f: - dparams = f['parameters'] + with h5py.File(cubic_studyset, "r") as f: + dparams = f["parameters"] y_study_cubic = dparams[:] y_shape_cubic = y_study_cubic.shape print("y_shape_cubic = ", y_shape_cubic) - with h5py.File(trigonal_studyset, 'r') as f: - dparams = f['parameters'] + with h5py.File(trigonal_studyset, "r") as f: + dparams = f["parameters"] y_study_trigonal = dparams[:] y_shape_trigonal = y_study_trigonal.shape print("y_shape_trigonal = ", y_shape_trigonal) - with h5py.File(tetragonal_studyset, 'r') as f: - dparams = f['parameters'] + with h5py.File(tetragonal_studyset, "r") as f: + dparams = f["parameters"] y_study_tetragonal = dparams[:] y_shape_tetragonal = y_study_tetragonal.shape print("y_shape_tetragonal = ", y_shape_tetragonal) - y_study = np.concatenate([y_study_cubic, y_study_trigonal, y_study_tetragonal], axis=0) + y_study = np.concatenate( + [y_study_cubic, y_study_trigonal, y_study_tetragonal], axis=0 + ) - freq = np.load('AL-freq.npy') - - sample_cubic, sample_trigonal, sample_tetragonal = create_all_samples(rank, size, global_seed, - y_study, freq, - cubic_bounding_box, - trigonal_bounding_box, alpha_diff_trigonal, - tetragonal_bounding_box, ac_diff_tetragonal) + freq = np.load("AL-freq.npy") - np.save('sample_cubic_rank_{}.npy'.format(rank), sample_cubic) - np.save('sample_trigonal_rank_{}.npy'.format(rank), sample_trigonal) - np.save('sample_tetragonal_rank_{}.npy'.format(rank), sample_tetragonal) + sample_cubic, sample_trigonal, sample_tetragonal = create_all_samples( + rank, + size, + global_seed, + y_study, + freq, + cubic_bounding_box, + trigonal_bounding_box, + alpha_diff_trigonal, + tetragonal_bounding_box, + ac_diff_tetragonal, + ) -if __name__ == '__main__': - main() + np.save(f"sample_cubic_rank_{rank}.npy", sample_cubic) + np.save(f"sample_trigonal_rank_{rank}.npy", sample_trigonal) + np.save(f"sample_tetragonal_rank_{rank}.npy", sample_tetragonal) + +if __name__ == "__main__": + main() diff --git a/examples/use_cases/neutron-scattering/scripts/simulation_resample_real_work.py b/examples/use_cases/neutron-scattering/scripts/simulation_resample_real_work.py index 03296835..da430bf2 100644 --- a/examples/use_cases/neutron-scattering/scripts/simulation_resample_real_work.py +++ b/examples/use_cases/neutron-scattering/scripts/simulation_resample_real_work.py @@ -1,27 +1,26 @@ #!/usr/bin/env python -import os,sys -sys.path.insert(0,os.path.expanduser("~/g2full/GSAS-II/GSASII/")) -import GSASIIscriptable as G2sc +import os +import sys -from mpi4py import MPI -import numpy as np +sys.path.insert(0, os.path.expanduser("~/g2full/GSAS-II/GSASII/")) import time -import h5py -from contextlib import redirect_stdout #for debug +import GSASIIscriptable as G2sc +import numpy as np import sweep_utils as su +from mpi4py import MPI gpx = [] phase = [] def cubic_lattice(prm): - """ This function uses a 1D grid. + """This function uses a 1D grid. - Parameters for cubic lattice: - a = b = c - alpha = beta = gamma = 90 + Parameters for cubic lattice: + a = b = c + alpha = beta = gamma = 90 """ global gpx @@ -29,32 +28,31 @@ def cubic_lattice(prm): # Unit cell parameters # Lattice - phase['General']['Cell'][1] = prm[0] # a - phase['General']['Cell'][2] = prm[0] # b - phase['General']['Cell'][3] = prm[0] # c + phase["General"]["Cell"][1] = prm[0] # a + phase["General"]["Cell"][2] = prm[0] # b + phase["General"]["Cell"][3] = prm[0] # c # Angles - phase['General']['Cell'][4] = 90 # alpha - phase['General']['Cell'][5] = 90 # beta - phase['General']['Cell'][6] = 90 # gamma + phase["General"]["Cell"][4] = 90 # alpha + phase["General"]["Cell"][5] = 90 # beta + phase["General"]["Cell"][6] = 90 # gamma # Compute simulation - gpx.data['Controls']['data']['max cyc']=0 + gpx.data["Controls"]["data"]["max cyc"] = 0 gpx.do_refinements([{}]) - x = gpx.histogram(0).getdata('x') - y = gpx.histogram(0).getdata('ycalc') + x = gpx.histogram(0).getdata("x") + y = gpx.histogram(0).getdata("ycalc") return x, y - def trigonal_lattice(prm): - """ This function uses a 2D grid. + """This function uses a 2D grid. - Parameters for trigonal lattice: - a = b = c - alpha = beta = gamma != 90 + Parameters for trigonal lattice: + a = b = c + alpha = beta = gamma != 90 """ global gpx @@ -62,32 +60,31 @@ def trigonal_lattice(prm): # Unit cell parameters # Lattice - phase['General']['Cell'][1] = prm[0] # a - phase['General']['Cell'][2] = prm[0] # b - phase['General']['Cell'][3] = prm[0] # c + phase["General"]["Cell"][1] = prm[0] # a + phase["General"]["Cell"][2] = prm[0] # b + phase["General"]["Cell"][3] = prm[0] # c # Angles - phase['General']['Cell'][4] = prm[1] # alpha - phase['General']['Cell'][5] = prm[1] # beta - phase['General']['Cell'][6] = prm[1] # gamma + phase["General"]["Cell"][4] = prm[1] # alpha + phase["General"]["Cell"][5] = prm[1] # beta + phase["General"]["Cell"][6] = prm[1] # gamma # Compute simulation - gpx.data['Controls']['data']['max cyc']=0 + gpx.data["Controls"]["data"]["max cyc"] = 0 gpx.do_refinements([{}]) - x = gpx.histogram(0).getdata('x') - y = gpx.histogram(0).getdata('ycalc') + x = gpx.histogram(0).getdata("x") + y = gpx.histogram(0).getdata("ycalc") return x, y - def tetragonal_lattice(prm): - """ This function uses a 2D grid. + """This function uses a 2D grid. - Parameters for tetragonal lattice: - a = b != c - alpha = beta = gamma = 90 + Parameters for tetragonal lattice: + a = b != c + alpha = beta = gamma = 90 """ global gpx @@ -96,21 +93,21 @@ def tetragonal_lattice(prm): # Unit cell parameters # Lattice # c should be differe - phase['General']['Cell'][1] = prm[0] # a - phase['General']['Cell'][2] = prm[0] # b - phase['General']['Cell'][3] = prm[1] # c + phase["General"]["Cell"][1] = prm[0] # a + phase["General"]["Cell"][2] = prm[0] # b + phase["General"]["Cell"][3] = prm[1] # c # Angles - phase['General']['Cell'][4] = 90 # alpha - phase['General']['Cell'][5] = 90 # beta - phase['General']['Cell'][6] = 90 # gamma + phase["General"]["Cell"][4] = 90 # alpha + phase["General"]["Cell"][5] = 90 # beta + phase["General"]["Cell"][6] = 90 # gamma # Compute simulation - gpx.data['Controls']['data']['max cyc']=0 + gpx.data["Controls"]["data"]["max cyc"] = 0 gpx.do_refinements([{}]) - x = gpx.histogram(0).getdata('x') - y = gpx.histogram(0).getdata('ycalc') + x = gpx.histogram(0).getdata("x") + y = gpx.histogram(0).getdata("ycalc") return x, y @@ -120,57 +117,64 @@ def _sim_impl(rank, size, sym, conffile_name, sample): gParameters = su.read_config_file(conffile_name) # Create project - name = gParameters['name'] +'_rank' + str(rank) - path_in = gParameters['path_in'] - path_out = gParameters['path_out'] - name_out = gParameters['name_out'] + name = gParameters["name"] + "_rank" + str(rank) + path_in = gParameters["path_in"] + path_out = gParameters["path_out"] + name_out = gParameters["name_out"] global gpx - gpx = G2sc.G2Project(newgpx=path_out+name+'.gpx') - + gpx = G2sc.G2Project(newgpx=path_out + name + ".gpx") + # Add phase: Requires CIF file - cif = path_in + gParameters['cif'] + cif = path_in + gParameters["cif"] global phase - phase = gpx.add_phase(cif,phasename=name,fmthint='CIF') - + phase = gpx.add_phase(cif, phasename=name, fmthint="CIF") + # Get instrument file specification - instprm = path_in + gParameters['instprm'] + instprm = path_in + gParameters["instprm"] # Histogram range - Tmin = gParameters['tmin'] - Tmax = gParameters['tmax'] - Tstep = gParameters['tstep'] - hist = gpx.add_simulated_powder_histogram(name+'TOFsimulation',instprm,Tmin,Tmax,Tstep,phases=gpx.phases()) - hist.SampleParameters['Scale'][0] = 1000. + Tmin = gParameters["tmin"] + Tmax = gParameters["tmax"] + Tstep = gParameters["tstep"] + hist = gpx.add_simulated_powder_histogram( + name + "TOFsimulation", instprm, Tmin, Tmax, Tstep, phases=gpx.phases() + ) + hist.SampleParameters["Scale"][0] = 1000.0 # Set to no-background - hist['Background'][0][3]=0.0 - - symmetry = gParameters['symmetry'] - assert symmetry == sym, "symmetry = {} and sym = {}".format(symmetry, sym) - + hist["Background"][0][3] = 0.0 + + symmetry = gParameters["symmetry"] + assert symmetry == sym, f"symmetry = {symmetry} and sym = {sym}" + # Configure sweep according to symmetry - if symmetry == 'cubic': + if symmetry == "cubic": sweepf_ = cubic_lattice - elif symmetry == 'trigonal': + elif symmetry == "trigonal": sweepf_ = trigonal_lattice - elif symmetry == 'tetragonal': + elif symmetry == "tetragonal": sweepf_ = tetragonal_lattice else: - exit("Do not recognize symmetry of {}".format(symmetry)) - + exit(f"Do not recognize symmetry of {symmetry}") + # Distribute computation - nsim, histosz = su.grid_sample(rank, size, sweepf_, sample, path_out, name_out + '_' + symmetry) + nsim, histosz = su.grid_sample( + rank, size, sweepf_, sample, path_out, name_out + "_" + symmetry + ) end = time.time() - print('----------------------------------------------------------') - print("Rank = {}, Number of simulations ({}): {}, size of histogram: {}, cost {} seconds".format(rank, symmetry, nsim, histosz, end-start)) + print("----------------------------------------------------------") + print( + f"Rank = {rank}, Number of simulations ({symmetry}): {nsim}, size of histogram: {histosz}, cost {end - start} seconds" + ) def main(): - start = time.time() - if ( len ( sys.argv ) != 6 ) : + if len(sys.argv) != 6: print(sys.argv) - sys.stderr.write("Usage: python simulation_resample_real_work.py " - "conf_name_cubic conf_name_trigonal conf_name_tetragonal " - "which_half first_half_percent\n") + sys.stderr.write( + "Usage: python simulation_resample_real_work.py " + "conf_name_cubic conf_name_trigonal conf_name_tetragonal " + "which_half first_half_percent\n" + ) sys.exit(0) comm = MPI.COMM_WORLD @@ -184,28 +188,27 @@ def main(): which_half = sys.argv[4] first_half_percent = float(sys.argv[5]) - sample_cubic = np.load('sample_cubic_rank_{}.npy'.format(rank)) - sample_trigonal = np.load('sample_trigonal_rank_{}.npy'.format(rank)) - sample_tetragonal = np.load('sample_tetragonal_rank_{}.npy'.format(rank)) + sample_cubic = np.load(f"sample_cubic_rank_{rank}.npy") + sample_trigonal = np.load(f"sample_trigonal_rank_{rank}.npy") + sample_tetragonal = np.load(f"sample_tetragonal_rank_{rank}.npy") - split_index_cubic = int(sample_cubic.shape[0] * first_half_percent) - split_index_trigonal = int(sample_trigonal.shape[0] * first_half_percent) + split_index_cubic = int(sample_cubic.shape[0] * first_half_percent) + split_index_trigonal = int(sample_trigonal.shape[0] * first_half_percent) split_index_tetragonal = int(sample_tetragonal.shape[0] * first_half_percent) if which_half == "first": - sample_cubic = sample_cubic[:split_index_cubic,:] - sample_trigonal = sample_trigonal[:split_index_trigonal,:] - sample_tetragonal = sample_tetragonal[:split_index_tetragonal,:] + sample_cubic = sample_cubic[:split_index_cubic, :] + sample_trigonal = sample_trigonal[:split_index_trigonal, :] + sample_tetragonal = sample_tetragonal[:split_index_tetragonal, :] elif which_half == "second": - sample_cubic = sample_cubic[split_index_cubic:,:] - sample_trigonal = sample_trigonal[split_index_trigonal:,:] - sample_tetragonal = sample_tetragonal[split_index_tetragonal:,:] + sample_cubic = sample_cubic[split_index_cubic:, :] + sample_trigonal = sample_trigonal[split_index_trigonal:, :] + sample_tetragonal = sample_tetragonal[split_index_tetragonal:, :] _sim_impl(rank, size, "cubic", conf_name_cubic, sample_cubic) _sim_impl(rank, size, "trigonal", conf_name_trigonal, sample_trigonal) _sim_impl(rank, size, "tetragonal", conf_name_tetragonal, sample_tetragonal) -if __name__ == '__main__': +if __name__ == "__main__": main() - diff --git a/examples/use_cases/neutron-scattering/scripts/simulation_sample.py b/examples/use_cases/neutron-scattering/scripts/simulation_sample.py index 069305a1..4a64af26 100644 --- a/examples/use_cases/neutron-scattering/scripts/simulation_sample.py +++ b/examples/use_cases/neutron-scattering/scripts/simulation_sample.py @@ -1,27 +1,26 @@ #!/usr/bin/env python -import os,sys -sys.path.insert(0,os.path.expanduser("~/g2full/GSAS-II/GSASII/")) -import GSASIIscriptable as G2sc +import os +import sys -from mpi4py import MPI -import numpy as np +sys.path.insert(0, os.path.expanduser("~/g2full/GSAS-II/GSASII/")) import time -import h5py -from contextlib import redirect_stdout #for debug +import GSASIIscriptable as G2sc +import numpy as np import sweep_utils as su +from mpi4py import MPI gpx = [] phase = [] def cubic_lattice(prm): - """ This function uses a 1D grid. + """This function uses a 1D grid. - Parameters for cubic lattice: - a = b = c - alpha = beta = gamma = 90 + Parameters for cubic lattice: + a = b = c + alpha = beta = gamma = 90 """ global gpx @@ -29,32 +28,31 @@ def cubic_lattice(prm): # Unit cell parameters # Lattice - phase['General']['Cell'][1] = prm[0] # a - phase['General']['Cell'][2] = prm[0] # b - phase['General']['Cell'][3] = prm[0] # c + phase["General"]["Cell"][1] = prm[0] # a + phase["General"]["Cell"][2] = prm[0] # b + phase["General"]["Cell"][3] = prm[0] # c # Angles - phase['General']['Cell'][4] = 90 # alpha - phase['General']['Cell'][5] = 90 # beta - phase['General']['Cell'][6] = 90 # gamma + phase["General"]["Cell"][4] = 90 # alpha + phase["General"]["Cell"][5] = 90 # beta + phase["General"]["Cell"][6] = 90 # gamma # Compute simulation - gpx.data['Controls']['data']['max cyc']=0 + gpx.data["Controls"]["data"]["max cyc"] = 0 gpx.do_refinements([{}]) - x = gpx.histogram(0).getdata('x') - y = gpx.histogram(0).getdata('ycalc') + x = gpx.histogram(0).getdata("x") + y = gpx.histogram(0).getdata("ycalc") return x, y - def trigonal_lattice(prm): - """ This function uses a 2D grid. + """This function uses a 2D grid. - Parameters for trigonal lattice: - a = b = c - alpha = beta = gamma != 90 + Parameters for trigonal lattice: + a = b = c + alpha = beta = gamma != 90 """ global gpx @@ -62,32 +60,31 @@ def trigonal_lattice(prm): # Unit cell parameters # Lattice - phase['General']['Cell'][1] = prm[0] # a - phase['General']['Cell'][2] = prm[0] # b - phase['General']['Cell'][3] = prm[0] # c + phase["General"]["Cell"][1] = prm[0] # a + phase["General"]["Cell"][2] = prm[0] # b + phase["General"]["Cell"][3] = prm[0] # c # Angles - phase['General']['Cell'][4] = prm[1] # alpha - phase['General']['Cell'][5] = prm[1] # beta - phase['General']['Cell'][6] = prm[1] # gamma + phase["General"]["Cell"][4] = prm[1] # alpha + phase["General"]["Cell"][5] = prm[1] # beta + phase["General"]["Cell"][6] = prm[1] # gamma # Compute simulation - gpx.data['Controls']['data']['max cyc']=0 + gpx.data["Controls"]["data"]["max cyc"] = 0 gpx.do_refinements([{}]) - x = gpx.histogram(0).getdata('x') - y = gpx.histogram(0).getdata('ycalc') + x = gpx.histogram(0).getdata("x") + y = gpx.histogram(0).getdata("ycalc") return x, y - def tetragonal_lattice(prm): - """ This function uses a 2D grid. + """This function uses a 2D grid. - Parameters for tetragonal lattice: - a = b != c - alpha = beta = gamma = 90 + Parameters for tetragonal lattice: + a = b != c + alpha = beta = gamma = 90 """ global gpx @@ -96,21 +93,21 @@ def tetragonal_lattice(prm): # Unit cell parameters # Lattice # c should be differe - phase['General']['Cell'][1] = prm[0] # a - phase['General']['Cell'][2] = prm[0] # b - phase['General']['Cell'][3] = prm[1] # c + phase["General"]["Cell"][1] = prm[0] # a + phase["General"]["Cell"][2] = prm[0] # b + phase["General"]["Cell"][3] = prm[1] # c # Angles - phase['General']['Cell'][4] = 90 # alpha - phase['General']['Cell'][5] = 90 # beta - phase['General']['Cell'][6] = 90 # gamma + phase["General"]["Cell"][4] = 90 # alpha + phase["General"]["Cell"][5] = 90 # beta + phase["General"]["Cell"][6] = 90 # gamma # Compute simulation - gpx.data['Controls']['data']['max cyc']=0 + gpx.data["Controls"]["data"]["max cyc"] = 0 gpx.do_refinements([{}]) - x = gpx.histogram(0).getdata('x') - y = gpx.histogram(0).getdata('ycalc') + x = gpx.histogram(0).getdata("x") + y = gpx.histogram(0).getdata("ycalc") return x, y @@ -119,132 +116,178 @@ def _generate_random_uniform(sz, low, high): random_numbers = np.random.uniform(low, high, sz) return random_numbers.reshape(-1, 1) + def generate_cubic_random_sample(sz, a_min, a_max): a_arr = _generate_random_uniform(sz, a_min, a_max) return a_arr + def generate_trigonal_random_sample(sz, a_min, a_max, alpha_min, alpha_max): a_arr = _generate_random_uniform(sz, a_min, a_max) alpha_arr = _generate_random_uniform(sz, alpha_min, alpha_max) return np.column_stack((a_arr, alpha_arr)) + def generate_tetragonal_random_sample(sz, a_min, a_max, c_min, c_max): a_arr = _generate_random_uniform(sz, a_min, a_max) c_arr = _generate_random_uniform(sz, c_min, c_max) return np.column_stack((a_arr, c_arr)) + def _remove_out_of_box(sym, sample_in, bounding_box, min_diff): - assert bounding_box.shape[1] == 2 #[[begin1, end1], [begin2, end2], [begin3, end3], ...] + assert ( + bounding_box.shape[1] == 2 + ) # [[begin1, end1], [begin2, end2], [begin3, end3], ...] if sym == "cubic": assert sample_in.shape[1] == 1 - assert bounding_box.shape[0] == 1 #on dim a - idx = (sample_in[:,0] >= bounding_box[0,0]) & (sample_in[:,0] <= bounding_box[0,1]) + assert bounding_box.shape[0] == 1 # on dim a + idx = (sample_in[:, 0] >= bounding_box[0, 0]) & ( + sample_in[:, 0] <= bounding_box[0, 1] + ) sample_in = sample_in[idx] elif sym == "trigonal": assert sample_in.shape[1] == 2 - assert bounding_box.shape[0] == 2 #on dim a, angle - idx = (sample_in[:,0] >= bounding_box[0,0]) & (sample_in[:,0] <= bounding_box[0,1]) & \ - (sample_in[:,1] >= bounding_box[1,0]) & (sample_in[:,1] <= bounding_box[1,1]) & \ - (abs(sample_in[:,1] - 90) > min_diff) #angle different from 90 degree + assert bounding_box.shape[0] == 2 # on dim a, angle + idx = ( + (sample_in[:, 0] >= bounding_box[0, 0]) + & (sample_in[:, 0] <= bounding_box[0, 1]) + & (sample_in[:, 1] >= bounding_box[1, 0]) + & (sample_in[:, 1] <= bounding_box[1, 1]) + & (abs(sample_in[:, 1] - 90) > min_diff) + ) # angle different from 90 degree sample_in = sample_in[idx] elif sym == "tetragonal": assert sample_in.shape[1] == 2 - assert bounding_box.shape[0] == 2 #on dim a, c - idx = (sample_in[:,0] >= bounding_box[0,0]) & (sample_in[:,0] <= bounding_box[0,1]) & \ - (sample_in[:,1] >= bounding_box[1,0]) & (sample_in[:,1] <= bounding_box[1,1]) & \ - (abs(sample_in[:,0] - sample_in[:,1]) > min_diff) #two edge should be different + assert bounding_box.shape[0] == 2 # on dim a, c + idx = ( + (sample_in[:, 0] >= bounding_box[0, 0]) + & (sample_in[:, 0] <= bounding_box[0, 1]) + & (sample_in[:, 1] >= bounding_box[1, 0]) + & (sample_in[:, 1] <= bounding_box[1, 1]) + & (abs(sample_in[:, 0] - sample_in[:, 1]) > min_diff) + ) # two edge should be different sample_in = sample_in[idx] else: - exit("Error! Unrecognized sym argument = {} in _remove_out_of_box function call".format(sym)) + exit( + f"Error! Unrecognized sym argument = {sym} in _remove_out_of_box function call" + ) return sample_in -#All rank will execute this function -#Here sz_g is the global total number of samples to simulate, which means each rank will only do sz_g/size samples -#Here x_min, x_max are global range, and will be the same for all ranks! -#Here seed_g is the global seed, and need to compute the local seed!!! -def create_all_samples(rank, size, sz_g, seed_g, - a_min1, a_max1, - a_min2, a_max2, alpha_min, alpha_max, - c_min, c_max, - cubic_bounding_box, - trigonal_bounding_box, alpha_diff_trigonal, - tetragonal_bounding_box, ac_diff_tetragonal): +# All rank will execute this function +# Here sz_g is the global total number of samples to simulate, which means each rank will only do sz_g/size samples +# Here x_min, x_max are global range, and will be the same for all ranks! +# Here seed_g is the global seed, and need to compute the local seed!!! +def create_all_samples( + rank, + size, + sz_g, + seed_g, + a_min1, + a_max1, + a_min2, + a_max2, + alpha_min, + alpha_max, + c_min, + c_max, + cubic_bounding_box, + trigonal_bounding_box, + alpha_diff_trigonal, + tetragonal_bounding_box, + ac_diff_tetragonal, +): sz = int(sz_g / size) seed = seed_g * 65537 + rank np.random.seed(seed) - print("IMPORTANT: rank = {}, seed for create_all_samples = {}!!!".format(rank, seed)) - - sample_cubic = generate_cubic_random_sample(sz, a_min1, a_max1) - sample_cubic = _remove_out_of_box("cubic", sample_cubic, cubic_bounding_box, None) - sample_trigonal = generate_trigonal_random_sample(sz, a_min2, a_max2, alpha_min, alpha_max) - sample_trigonal = _remove_out_of_box("trigonal", sample_trigonal, trigonal_bounding_box, alpha_diff_trigonal) - sample_tetragonal = generate_tetragonal_random_sample(sz, a_min2, a_max2, c_min, c_max) - sample_tetragonal = _remove_out_of_box("tetragonal", sample_tetragonal, tetragonal_bounding_box, ac_diff_tetragonal) + print(f"IMPORTANT: rank = {rank}, seed for create_all_samples = {seed}!!!") + + sample_cubic = generate_cubic_random_sample(sz, a_min1, a_max1) + sample_cubic = _remove_out_of_box("cubic", sample_cubic, cubic_bounding_box, None) + sample_trigonal = generate_trigonal_random_sample( + sz, a_min2, a_max2, alpha_min, alpha_max + ) + sample_trigonal = _remove_out_of_box( + "trigonal", sample_trigonal, trigonal_bounding_box, alpha_diff_trigonal + ) + sample_tetragonal = generate_tetragonal_random_sample( + sz, a_min2, a_max2, c_min, c_max + ) + sample_tetragonal = _remove_out_of_box( + "tetragonal", sample_tetragonal, tetragonal_bounding_box, ac_diff_tetragonal + ) return sample_cubic, sample_trigonal, sample_tetragonal + def _sim_impl(rank, size, sym, conffile_name, sample): start = time.time() gParameters = su.read_config_file(conffile_name) # Create project - name = gParameters['name'] +'_rank' + str(rank) - path_in = gParameters['path_in'] - path_out = gParameters['path_out'] - name_out = gParameters['name_out'] + name = gParameters["name"] + "_rank" + str(rank) + path_in = gParameters["path_in"] + path_out = gParameters["path_out"] + name_out = gParameters["name_out"] global gpx - gpx = G2sc.G2Project(newgpx=path_out+name+'.gpx') - + gpx = G2sc.G2Project(newgpx=path_out + name + ".gpx") + # Add phase: Requires CIF file - cif = path_in + gParameters['cif'] + cif = path_in + gParameters["cif"] global phase - phase = gpx.add_phase(cif,phasename=name,fmthint='CIF') - + phase = gpx.add_phase(cif, phasename=name, fmthint="CIF") + # Get instrument file specification - instprm = path_in + gParameters['instprm'] + instprm = path_in + gParameters["instprm"] # Histogram range - Tmin = gParameters['tmin'] - Tmax = gParameters['tmax'] - Tstep = gParameters['tstep'] - hist = gpx.add_simulated_powder_histogram(name+'TOFsimulation',instprm,Tmin,Tmax,Tstep,phases=gpx.phases()) - hist.SampleParameters['Scale'][0] = 1000. + Tmin = gParameters["tmin"] + Tmax = gParameters["tmax"] + Tstep = gParameters["tstep"] + hist = gpx.add_simulated_powder_histogram( + name + "TOFsimulation", instprm, Tmin, Tmax, Tstep, phases=gpx.phases() + ) + hist.SampleParameters["Scale"][0] = 1000.0 # Set to no-background - hist['Background'][0][3]=0.0 - - symmetry = gParameters['symmetry'] - assert symmetry == sym, "symmetry = {} and sym = {}".format(symmetry, sym) - + hist["Background"][0][3] = 0.0 + + symmetry = gParameters["symmetry"] + assert symmetry == sym, f"symmetry = {symmetry} and sym = {sym}" + # Configure sweep according to symmetry - if symmetry == 'cubic': + if symmetry == "cubic": sweepf_ = cubic_lattice - elif symmetry == 'trigonal': + elif symmetry == "trigonal": sweepf_ = trigonal_lattice - elif symmetry == 'tetragonal': + elif symmetry == "tetragonal": sweepf_ = tetragonal_lattice else: - exit("Do not recognize symmetry of {}".format(symmetry)) - + exit(f"Do not recognize symmetry of {symmetry}") + # Distribute computation - nsim, histosz = su.grid_sample(rank, size, sweepf_, sample, path_out, name_out + '_' + symmetry) + nsim, histosz = su.grid_sample( + rank, size, sweepf_, sample, path_out, name_out + "_" + symmetry + ) end = time.time() - print('----------------------------------------------------------') - print("Rank = {}, Number of simulations ({}): {}, size of histogram: {}, cost {} seconds".format(rank, symmetry, nsim, histosz, end-start)) + print("----------------------------------------------------------") + print( + f"Rank = {rank}, Number of simulations ({symmetry}): {nsim}, size of histogram: {histosz}, cost {end - start} seconds" + ) def main(): - start = time.time() - if ( len ( sys.argv ) != 6 ) : + if len(sys.argv) != 6: print(sys.argv) - sys.stderr.write("Usage: python simulation_sample.py num_sample_total " - "global_seed conf_name_cubic conf_name_trigonal conf_name_tetragonal\n") + sys.stderr.write( + "Usage: python simulation_sample.py num_sample_total " + "global_seed conf_name_cubic conf_name_trigonal conf_name_tetragonal\n" + ) sys.exit(0) comm = MPI.COMM_WORLD size = comm.Get_size() rank = comm.Get_rank() - print("Rank = {} out of size = {}".format(rank, size)) + print(f"Rank = {rank} out of size = {size}") num_sample_total = int(sys.argv[1]) global_seed = int(sys.argv[2]) @@ -252,7 +295,7 @@ def main(): conf_name_trigonal = sys.argv[4] conf_name_tetragonal = sys.argv[5] -#FIXME + # FIXME a_min1 = 2.5 a_max1 = 5.5 a_min2 = 3.5 @@ -268,14 +311,25 @@ def main(): alpha_diff_trigonal = 0.2 ac_diff_tetragonal = 0.001 - sample_cubic, sample_trigonal, sample_tetragonal = create_all_samples(rank, size, - num_sample_total, global_seed, - a_min1, a_max1, - a_min2, a_max2, alpha_min, alpha_max, - c_min, c_max, - cubic_bounding_box, - trigonal_bounding_box, alpha_diff_trigonal, - tetragonal_bounding_box, ac_diff_tetragonal) + sample_cubic, sample_trigonal, sample_tetragonal = create_all_samples( + rank, + size, + num_sample_total, + global_seed, + a_min1, + a_max1, + a_min2, + a_max2, + alpha_min, + alpha_max, + c_min, + c_max, + cubic_bounding_box, + trigonal_bounding_box, + alpha_diff_trigonal, + tetragonal_bounding_box, + ac_diff_tetragonal, + ) print("sample_cubic = ", sample_cubic) print("sample_trigonal = ", sample_trigonal) print("sample_tetragonal = ", sample_tetragonal) @@ -285,6 +339,5 @@ def main(): _sim_impl(rank, size, "tetragonal", conf_name_tetragonal, sample_tetragonal) -if __name__ == '__main__': +if __name__ == "__main__": main() - diff --git a/examples/use_cases/neutron-scattering/scripts/simulation_sweep.py b/examples/use_cases/neutron-scattering/scripts/simulation_sweep.py index ba456a4a..6aee464d 100644 --- a/examples/use_cases/neutron-scattering/scripts/simulation_sweep.py +++ b/examples/use_cases/neutron-scattering/scripts/simulation_sweep.py @@ -1,27 +1,26 @@ #!/usr/bin/env python -import os,sys -sys.path.insert(0,os.path.expanduser("~/g2full/GSAS-II/GSASII/")) -import GSASIIscriptable as G2sc +import os +import sys -from mpi4py import MPI -import numpy as np +sys.path.insert(0, os.path.expanduser("~/g2full/GSAS-II/GSASII/")) import time -import h5py -from contextlib import redirect_stdout #for debug +import GSASIIscriptable as G2sc +import numpy as np import sweep_utils as su +from mpi4py import MPI gpx = [] phase = [] def cubic_lattice(prm): - """ This function uses a 1D grid. + """This function uses a 1D grid. - Parameters for cubic lattice: - a = b = c - alpha = beta = gamma = 90 + Parameters for cubic lattice: + a = b = c + alpha = beta = gamma = 90 """ global gpx @@ -29,32 +28,31 @@ def cubic_lattice(prm): # Unit cell parameters # Lattice - phase['General']['Cell'][1] = prm[0] # a - phase['General']['Cell'][2] = prm[0] # b - phase['General']['Cell'][3] = prm[0] # c + phase["General"]["Cell"][1] = prm[0] # a + phase["General"]["Cell"][2] = prm[0] # b + phase["General"]["Cell"][3] = prm[0] # c # Angles - phase['General']['Cell'][4] = 90 # alpha - phase['General']['Cell'][5] = 90 # beta - phase['General']['Cell'][6] = 90 # gamma + phase["General"]["Cell"][4] = 90 # alpha + phase["General"]["Cell"][5] = 90 # beta + phase["General"]["Cell"][6] = 90 # gamma # Compute simulation - gpx.data['Controls']['data']['max cyc']=0 + gpx.data["Controls"]["data"]["max cyc"] = 0 gpx.do_refinements([{}]) - x = gpx.histogram(0).getdata('x') - y = gpx.histogram(0).getdata('ycalc') + x = gpx.histogram(0).getdata("x") + y = gpx.histogram(0).getdata("ycalc") return x, y - def trigonal_lattice(prm): - """ This function uses a 2D grid. + """This function uses a 2D grid. - Parameters for trigonal lattice: - a = b = c - alpha = beta = gamma != 90 + Parameters for trigonal lattice: + a = b = c + alpha = beta = gamma != 90 """ global gpx @@ -62,32 +60,31 @@ def trigonal_lattice(prm): # Unit cell parameters # Lattice - phase['General']['Cell'][1] = prm[0] # a - phase['General']['Cell'][2] = prm[0] # b - phase['General']['Cell'][3] = prm[0] # c + phase["General"]["Cell"][1] = prm[0] # a + phase["General"]["Cell"][2] = prm[0] # b + phase["General"]["Cell"][3] = prm[0] # c # Angles - phase['General']['Cell'][4] = prm[1] # alpha - phase['General']['Cell'][5] = prm[1] # beta - phase['General']['Cell'][6] = prm[1] # gamma + phase["General"]["Cell"][4] = prm[1] # alpha + phase["General"]["Cell"][5] = prm[1] # beta + phase["General"]["Cell"][6] = prm[1] # gamma # Compute simulation - gpx.data['Controls']['data']['max cyc']=0 + gpx.data["Controls"]["data"]["max cyc"] = 0 gpx.do_refinements([{}]) - x = gpx.histogram(0).getdata('x') - y = gpx.histogram(0).getdata('ycalc') + x = gpx.histogram(0).getdata("x") + y = gpx.histogram(0).getdata("ycalc") return x, y - def tetragonal_lattice(prm): - """ This function uses a 2D grid. + """This function uses a 2D grid. - Parameters for tetragonal lattice: - a = b != c - alpha = beta = gamma = 90 + Parameters for tetragonal lattice: + a = b != c + alpha = beta = gamma = 90 """ global gpx @@ -96,21 +93,21 @@ def tetragonal_lattice(prm): # Unit cell parameters # Lattice # c should be differe - phase['General']['Cell'][1] = prm[0] # a - phase['General']['Cell'][2] = prm[0] # b - phase['General']['Cell'][3] = prm[1] # c + phase["General"]["Cell"][1] = prm[0] # a + phase["General"]["Cell"][2] = prm[0] # b + phase["General"]["Cell"][3] = prm[1] # c # Angles - phase['General']['Cell'][4] = 90 # alpha - phase['General']['Cell'][5] = 90 # beta - phase['General']['Cell'][6] = 90 # gamma + phase["General"]["Cell"][4] = 90 # alpha + phase["General"]["Cell"][5] = 90 # beta + phase["General"]["Cell"][6] = 90 # gamma # Compute simulation - gpx.data['Controls']['data']['max cyc']=0 + gpx.data["Controls"]["data"]["max cyc"] = 0 gpx.do_refinements([{}]) - x = gpx.histogram(0).getdata('x') - y = gpx.histogram(0).getdata('ycalc') + x = gpx.histogram(0).getdata("x") + y = gpx.histogram(0).getdata("ycalc") return x, y @@ -119,152 +116,205 @@ def _sweep_uniform(sz, low, high): uniform_numbers = np.linspace(low, high, sz) return uniform_numbers.reshape(-1, 1) + def sweep_cubic_sample(sz, a_min, a_max): a_arr = _sweep_uniform(sz, a_min, a_max) return a_arr + def sweep_trigonal_sample(sz_a, sz_alpha, a_min, a_max, alpha_min, alpha_max): a_arr = _sweep_uniform(sz_a, a_min, a_max) alpha_arr = _sweep_uniform(sz_alpha, alpha_min, alpha_max) - a_mesh, alpha_mesh = np.meshgrid(a_arr.flatten(), alpha_arr.flatten(), indexing='ij') + a_mesh, alpha_mesh = np.meshgrid( + a_arr.flatten(), alpha_arr.flatten(), indexing="ij" + ) return np.column_stack((a_mesh.ravel(), alpha_mesh.ravel())) + def sweep_tetragonal_sample(sz_a, sz_c, a_min, a_max, c_min, c_max): a_arr = _sweep_uniform(sz_a, a_min, a_max) c_arr = _sweep_uniform(sz_c, c_min, c_max) - a_mesh, c_mesh = np.meshgrid(a_arr.flatten(), c_arr.flatten(), indexing='ij') + a_mesh, c_mesh = np.meshgrid(a_arr.flatten(), c_arr.flatten(), indexing="ij") return np.column_stack((a_mesh.ravel(), c_mesh.ravel())) + def _remove_out_of_box(sym, sample_in, bounding_box, min_diff): - assert bounding_box.shape[1] == 2 #[[begin1, end1], [begin2, end2], [begin3, end3], ...] + assert ( + bounding_box.shape[1] == 2 + ) # [[begin1, end1], [begin2, end2], [begin3, end3], ...] if sym == "cubic": assert sample_in.shape[1] == 1 - assert bounding_box.shape[0] == 1 #on dim a - idx = (sample_in[:,0] >= bounding_box[0,0]) & (sample_in[:,0] <= bounding_box[0,1]) + assert bounding_box.shape[0] == 1 # on dim a + idx = (sample_in[:, 0] >= bounding_box[0, 0]) & ( + sample_in[:, 0] <= bounding_box[0, 1] + ) sample_in = sample_in[idx] elif sym == "trigonal": assert sample_in.shape[1] == 2 - assert bounding_box.shape[0] == 2 #on dim a, angle - idx = (sample_in[:,0] >= bounding_box[0,0]) & (sample_in[:,0] <= bounding_box[0,1]) & \ - (sample_in[:,1] >= bounding_box[1,0]) & (sample_in[:,1] <= bounding_box[1,1]) & \ - (abs(sample_in[:,1] - 90) > min_diff) #angle different from 90 degree + assert bounding_box.shape[0] == 2 # on dim a, angle + idx = ( + (sample_in[:, 0] >= bounding_box[0, 0]) + & (sample_in[:, 0] <= bounding_box[0, 1]) + & (sample_in[:, 1] >= bounding_box[1, 0]) + & (sample_in[:, 1] <= bounding_box[1, 1]) + & (abs(sample_in[:, 1] - 90) > min_diff) + ) # angle different from 90 degree sample_in = sample_in[idx] elif sym == "tetragonal": assert sample_in.shape[1] == 2 - assert bounding_box.shape[0] == 2 #on dim a, c - idx = (sample_in[:,0] >= bounding_box[0,0]) & (sample_in[:,0] <= bounding_box[0,1]) & \ - (sample_in[:,1] >= bounding_box[1,0]) & (sample_in[:,1] <= bounding_box[1,1]) & \ - (abs(sample_in[:,0] - sample_in[:,1]) > min_diff) #two edge should be different + assert bounding_box.shape[0] == 2 # on dim a, c + idx = ( + (sample_in[:, 0] >= bounding_box[0, 0]) + & (sample_in[:, 0] <= bounding_box[0, 1]) + & (sample_in[:, 1] >= bounding_box[1, 0]) + & (sample_in[:, 1] <= bounding_box[1, 1]) + & (abs(sample_in[:, 0] - sample_in[:, 1]) > min_diff) + ) # two edge should be different sample_in = sample_in[idx] else: - exit("Error! Unrecognized sym argument = {} in _remove_out_of_box function call".format(sym)) + exit( + f"Error! Unrecognized sym argument = {sym} in _remove_out_of_box function call" + ) return sample_in -#All rank will execute this function -#Here sz_g is the global total number of samples to simulate, which means each rank will only do sz_g/size samples -#Here x_min, x_max are global range, and need to compute some of local range inside this function -#Different rank will have different local a_min and a_max that are non-overlapping, but alpha/c are the same -def create_all_samples(rank, size, sz_g, - a_min1, a_max1, - a_min2, a_max2, alpha_min, alpha_max, - c_min, c_max, - cubic_bounding_box, - trigonal_bounding_box, alpha_diff_trigonal, - tetragonal_bounding_box, ac_diff_tetragonal): +# All rank will execute this function +# Here sz_g is the global total number of samples to simulate, which means each rank will only do sz_g/size samples +# Here x_min, x_max are global range, and need to compute some of local range inside this function +# Different rank will have different local a_min and a_max that are non-overlapping, but alpha/c are the same +def create_all_samples( + rank, + size, + sz_g, + a_min1, + a_max1, + a_min2, + a_max2, + alpha_min, + alpha_max, + c_min, + c_max, + cubic_bounding_box, + trigonal_bounding_box, + alpha_diff_trigonal, + tetragonal_bounding_box, + ac_diff_tetragonal, +): sz = int(sz_g / size) diff = (a_max1 - a_min1) / (sz * size - 1) start_off = a_min1 + diff * sz * rank - end_off = a_min1 + diff * (sz * (rank + 1) - 1) - print("IMPORTANT: rank = {}, for cubic, sz = {}, start_off = {}, end_off = {}!!!".format(rank, sz, start_off, end_off)) + end_off = a_min1 + diff * (sz * (rank + 1) - 1) + print( + f"IMPORTANT: rank = {rank}, for cubic, sz = {sz}, start_off = {start_off}, end_off = {end_off}!!!" + ) sample_cubic = sweep_cubic_sample(sz, start_off, end_off) sample_cubic = _remove_out_of_box("cubic", sample_cubic, cubic_bounding_box, None) sz_dim2 = int(np.sqrt(sz_g)) sz_a = int(sz_dim2 / size) - assert sz_a >= 1, "Each thread only has little a_grid to do, need different algorithm!" + assert sz_a >= 1, ( + "Each thread only has little a_grid to do, need different algorithm!" + ) diff = (a_max2 - a_min2) / (sz_a * size - 1) start_off = a_min2 + diff * sz_a * rank - end_off = a_min2 + diff * (sz_a * (rank + 1) - 1) - print("IMPORTANT: rank = {}, for trigonal/tetragonal, sz_a = {}, sz_dim2 = {}, start_off = {}, end_off = {}!!!".format(rank, sz_a, sz_dim2, start_off, end_off)) - - sample_trigonal = sweep_trigonal_sample(sz_a, sz_dim2, start_off, end_off, alpha_min, alpha_max) - sample_trigonal = _remove_out_of_box("trigonal", sample_trigonal, trigonal_bounding_box, alpha_diff_trigonal) - - sample_tetragonal = sweep_tetragonal_sample(sz_a, sz_dim2, start_off, end_off, c_min, c_max) - sample_tetragonal = _remove_out_of_box("tetragonal", sample_tetragonal, tetragonal_bounding_box, ac_diff_tetragonal) + end_off = a_min2 + diff * (sz_a * (rank + 1) - 1) + print( + f"IMPORTANT: rank = {rank}, for trigonal/tetragonal, sz_a = {sz_a}, sz_dim2 = {sz_dim2}, start_off = {start_off}, end_off = {end_off}!!!" + ) + + sample_trigonal = sweep_trigonal_sample( + sz_a, sz_dim2, start_off, end_off, alpha_min, alpha_max + ) + sample_trigonal = _remove_out_of_box( + "trigonal", sample_trigonal, trigonal_bounding_box, alpha_diff_trigonal + ) + + sample_tetragonal = sweep_tetragonal_sample( + sz_a, sz_dim2, start_off, end_off, c_min, c_max + ) + sample_tetragonal = _remove_out_of_box( + "tetragonal", sample_tetragonal, tetragonal_bounding_box, ac_diff_tetragonal + ) return sample_cubic, sample_trigonal, sample_tetragonal + def _sim_impl(rank, size, sym, conffile_name, sample): start = time.time() gParameters = su.read_config_file(conffile_name) # Create project - name = gParameters['name'] +'_rank' + str(rank) - path_in = gParameters['path_in'] - path_out = gParameters['path_out'] - name_out = gParameters['name_out'] + name = gParameters["name"] + "_rank" + str(rank) + path_in = gParameters["path_in"] + path_out = gParameters["path_out"] + name_out = gParameters["name_out"] global gpx - gpx = G2sc.G2Project(newgpx=path_out+name+'.gpx') - + gpx = G2sc.G2Project(newgpx=path_out + name + ".gpx") + # Add phase: Requires CIF file - cif = path_in + gParameters['cif'] + cif = path_in + gParameters["cif"] global phase - phase = gpx.add_phase(cif,phasename=name,fmthint='CIF') - + phase = gpx.add_phase(cif, phasename=name, fmthint="CIF") + # Get instrument file specification - instprm = path_in + gParameters['instprm'] + instprm = path_in + gParameters["instprm"] # Histogram range - Tmin = gParameters['tmin'] - Tmax = gParameters['tmax'] - Tstep = gParameters['tstep'] - hist = gpx.add_simulated_powder_histogram(name+'TOFsimulation',instprm,Tmin,Tmax,Tstep,phases=gpx.phases()) - hist.SampleParameters['Scale'][0] = 1000. + Tmin = gParameters["tmin"] + Tmax = gParameters["tmax"] + Tstep = gParameters["tstep"] + hist = gpx.add_simulated_powder_histogram( + name + "TOFsimulation", instprm, Tmin, Tmax, Tstep, phases=gpx.phases() + ) + hist.SampleParameters["Scale"][0] = 1000.0 # Set to no-background - hist['Background'][0][3]=0.0 - - symmetry = gParameters['symmetry'] - assert symmetry == sym, "symmetry = {} and sym = {}".format(symmetry, sym) - + hist["Background"][0][3] = 0.0 + + symmetry = gParameters["symmetry"] + assert symmetry == sym, f"symmetry = {symmetry} and sym = {sym}" + # Configure sweep according to symmetry - if symmetry == 'cubic': + if symmetry == "cubic": sweepf_ = cubic_lattice - elif symmetry == 'trigonal': + elif symmetry == "trigonal": sweepf_ = trigonal_lattice - elif symmetry == 'tetragonal': + elif symmetry == "tetragonal": sweepf_ = tetragonal_lattice else: - exit("Do not recognize symmetry of {}".format(symmetry)) - + exit(f"Do not recognize symmetry of {symmetry}") + # Distribute computation - nsim, histosz = su.grid_sample(rank, size, sweepf_, sample, path_out, name_out + '_' + symmetry) + nsim, histosz = su.grid_sample( + rank, size, sweepf_, sample, path_out, name_out + "_" + symmetry + ) end = time.time() - print('----------------------------------------------------------') - print("Rank = {}, Number of simulations ({}): {}, size of histogram: {}, cost {} seconds".format(rank, symmetry, nsim, histosz, end-start)) + print("----------------------------------------------------------") + print( + f"Rank = {rank}, Number of simulations ({symmetry}): {nsim}, size of histogram: {histosz}, cost {end - start} seconds" + ) def main(): - start = time.time() - if ( len ( sys.argv ) != 5 ) : + if len(sys.argv) != 5: print(sys.argv) - sys.stderr.write("Usage: python simulation_sweep.py num_sample_total " - "conf_name_cubic, conf_name_trigonal, conf_name_tetragonal\n") + sys.stderr.write( + "Usage: python simulation_sweep.py num_sample_total " + "conf_name_cubic, conf_name_trigonal, conf_name_tetragonal\n" + ) sys.exit(0) comm = MPI.COMM_WORLD size = comm.Get_size() rank = comm.Get_rank() - print("Rank = {} out of size = {}".format(rank, size)) + print(f"Rank = {rank} out of size = {size}") num_sample_total = int(sys.argv[1]) conf_name_cubic = sys.argv[2] conf_name_trigonal = sys.argv[3] conf_name_tetragonal = sys.argv[4] -#FIXME + # FIXME a_min1 = 2.5 a_max1 = 5.5 a_min2 = 3.5 @@ -280,14 +330,24 @@ def main(): alpha_diff_trigonal = 0.2 ac_diff_tetragonal = 0.001 - sample_cubic, sample_trigonal, sample_tetragonal = create_all_samples(rank, size, - num_sample_total, - a_min1, a_max1, - a_min2, a_max2, alpha_min, alpha_max, - c_min, c_max, - cubic_bounding_box, - trigonal_bounding_box, alpha_diff_trigonal, - tetragonal_bounding_box, ac_diff_tetragonal) + sample_cubic, sample_trigonal, sample_tetragonal = create_all_samples( + rank, + size, + num_sample_total, + a_min1, + a_max1, + a_min2, + a_max2, + alpha_min, + alpha_max, + c_min, + c_max, + cubic_bounding_box, + trigonal_bounding_box, + alpha_diff_trigonal, + tetragonal_bounding_box, + ac_diff_tetragonal, + ) print(sample_cubic) print(sample_trigonal) print(sample_tetragonal) @@ -297,6 +357,5 @@ def main(): _sim_impl(rank, size, "tetragonal", conf_name_tetragonal, sample_tetragonal) -if __name__ == '__main__': +if __name__ == "__main__": main() - diff --git a/examples/use_cases/neutron-scattering/scripts/sweep_utils.py b/examples/use_cases/neutron-scattering/scripts/sweep_utils.py index fa3d9a90..fc425c02 100644 --- a/examples/use_cases/neutron-scattering/scripts/sweep_utils.py +++ b/examples/use_cases/neutron-scattering/scripts/sweep_utils.py @@ -1,12 +1,9 @@ -from __future__ import absolute_import - +import itertools from pprint import pprint +import h5py import numpy as np from mpi4py import MPI -import itertools -import h5py - try: import configparser @@ -15,13 +12,13 @@ def read_config_file(file): - """Functionality to read the configue file. + """Functionality to read the configure file. Parameters ---------- file : string Name of configuration file. - + Returns ------- fileParams : python dictionary @@ -29,35 +26,35 @@ def read_config_file(file): (key, value) tuples. """ - config=configparser.ConfigParser() + config = configparser.ConfigParser() config.read(file) - section=config.sections() - fileParams={} - + section = config.sections() + fileParams = {} + # parse specified arguments (minimal validation: if arguments # are written several times in the file, just the first time # will be used) for sec in section: - for k,v in config.items(sec): - if not k in fileParams: + for k, v in config.items(sec): + if k not in fileParams: fileParams[k] = eval(v) - + pprint(fileParams) return fileParams def read_sweep_ranges(pdict): - """Functionality to extract range values from python dictionary. - The operation is applied over dictionary elements with key starting - by the string 'sweep'. The ranges are returned in a dictionary. + """Functionality to extract range values from python dictionary. The operation is applied over + dictionary elements with key starting by the string 'sweep'. The ranges are returned in a + dictionary. Parameters ---------- pdict : python dictionary The dictionary include all the configuration parameters read from the configuration file. - + Returns ------- ranges : python dictionary @@ -66,11 +63,11 @@ def read_sweep_ranges(pdict): to be configured as: (initial, final, step). """ - ranges={} + ranges = {} for key in pdict.keys(): - key_list = key.split('_') - if key_list[0] == 'sweep': - key_out = key_list[1] + '_' + key_list[2] + key_list = key.split("_") + if key_list[0] == "sweep": + key_out = key_list[1] + "_" + key_list[2] ranges[key_out] = pdict[key] return ranges @@ -80,11 +77,11 @@ def read_sweep_ranges(pdict): # MPI functionality ###################### + def _get_rank_limits(comm, arrlen): - """Determine the chunk of the grid that has to be computed per - process. The grid has been 'flattened' and has arrlen length. The - chunk assigned to each process depends on its rank in the MPI - communicator. + """Determine the chunk of the grid that has to be computed per process. The grid has been + 'flattened' and has arrlen length. The chunk assigned to each process depends on its rank in the + MPI communicator. Parameters ---------- @@ -119,12 +116,11 @@ def _get_rank_limits(comm, arrlen): def grid_sweep(fn, grid, prefix, fnout, comm=None): - """Perform a grid sweep launching simulation processes for each - combination of parameters specified. It is assumed that each - simulation returns an np array. The computation of the simulation - at the grid points is executed in parallel using MPI. The simulation - results (together with the launching parameters) are stored in hdf5 files - independently by each process in the communicator. + """Perform a grid sweep launching simulation processes for each combination of parameters + specified. It is assumed that each simulation returns an np array. The computation of the + simulation at the grid points is executed in parallel using MPI. The simulation results + (together with the launching parameters) are stored in hdf5 files independently by each process + in the communicator. The `mpi4py `__ package is required for use of the ``mpiutil.grid_search`` function. It is also @@ -173,45 +169,49 @@ def grid_sweep(fn, grid, prefix, fnout, comm=None): fprm = itertools.product(*grid) rank = comm.Get_rank() - print("TW: rank = {}, print fprm!\n".format(rank), fprm) + print(f"TW: rank = {rank}, print fprm!\n", fprm) # Distribute computation among processes in MPI communicator afprm = np.asarray(list(fprm)) # Faster to communicate array data - print("TW: rank = {}, print afprm!\n".format(rank), afprm) + print(f"TW: rank = {rank}, print afprm!\n", afprm) print("TW: rank = ", rank, " shape of afprm = ", afprm.shape) iterlen = afprm.shape[0] begin, end = _get_rank_limits(comm, iterlen) - rankgrid = (afprm[begin:end, :]) + rankgrid = afprm[begin:end, :] if rank == 0: print(type(rankgrid)) print("rankgrid = ", rankgrid) rankfval = np.asarray(list(map(fn, rankgrid))) - print("TW: rank = ", rank, " iterlen = ", iterlen, " begin = ", begin, " end = ", end) - print("TW: shape of rankfval = ", rankfval.shape, " shape of rankgrid = ", rankgrid.shape) - - fname = prefix + fnout + '_part' + str(rank) + '.hdf5' - with h5py.File(fname, 'w') as f: - f.create_dataset('histograms', data=rankfval) - f.create_dataset('parameters', data=rankgrid) -# print('Data generated stored in file: ', fname) - - -# print('rankfval.shape: ', rankfval.shape) + print( + "TW: rank = ", rank, " iterlen = ", iterlen, " begin = ", begin, " end = ", end + ) + print( + "TW: shape of rankfval = ", + rankfval.shape, + " shape of rankgrid = ", + rankgrid.shape, + ) + + fname = prefix + fnout + "_part" + str(rank) + ".hdf5" + with h5py.File(fname, "w") as f: + f.create_dataset("histograms", data=rankfval) + f.create_dataset("parameters", data=rankgrid) + # print('Data generated stored in file: ', fname) + + # print('rankfval.shape: ', rankfval.shape) return iterlen, rankfval.shape[2] def grid_sample(rank, size, fn, sample, prefix, fnout): - - print("TW: rank = {}, sample.shape = {}, sample = \n{}".format(rank, sample.shape, sample)) + print(f"TW: rank = {rank}, sample.shape = {sample.shape}, sample = \n{sample}") rankfval = np.asarray(list(map(fn, sample))) - print("TW: rank = {}, shape of rankfval = ".format(rank), rankfval.shape) + print(f"TW: rank = {rank}, shape of rankfval = ", rankfval.shape) - fname = prefix + fnout + '_part' + str(rank) + '.hdf5' - with h5py.File(fname, 'w') as f: - f.create_dataset('histograms', data=rankfval) - f.create_dataset('parameters', data=sample) + fname = prefix + fnout + "_part" + str(rank) + ".hdf5" + with h5py.File(fname, "w") as f: + f.create_dataset("histograms", data=rankfval) + f.create_dataset("parameters", data=sample) return sample.shape[0], rankfval.shape[2] - diff --git a/examples/use_cases/neutron-scattering/scripts/train.py b/examples/use_cases/neutron-scattering/scripts/train.py index 73c716db..4813fec1 100644 --- a/examples/use_cases/neutron-scattering/scripts/train.py +++ b/examples/use_cases/neutron-scattering/scripts/train.py @@ -1,61 +1,94 @@ #!/usr/bin/env python print("Start at the beginning of training!") -import io, os, sys +import argparse +import copy +import os +import random import time + +import compute_kernel as ker +import model as mdp import numpy as np -import matplotlib -import matplotlib.pyplot as plt import torch -import random -import copy -import h5py -import argparse import torch.utils.data.distributed -import torch.distributed as dist -from torch.nn.parallel import DistributedDataParallel as DDP - -import model as mdp import util -import compute_kernel as ker - +from torch.nn.parallel import DistributedDataParallel as DDP script_start_time = time.time() -#----------------------Parser settings--------------------------- +# ----------------------Parser settings--------------------------- print("Start setting parser!", flush=True) -parser = argparse.ArgumentParser(description='Exalearn_Training_v1') - -parser.add_argument('--batch_size', type=int, default=2048, - help='input batch size for training (default: 2048)') -parser.add_argument('--epochs', type=int, default=1000, - help='number of epochs to train, save the best model instead of the model at last epoch (default: 1000)') -parser.add_argument('--lr', type=float, default=0.0005, - help='learning rate (default: 0.0005)') -parser.add_argument('--seed', type=int, default=42, - help='random seed (default: 42)') -parser.add_argument('--log_interval', type=int, default=1, - help='how many batches to wait before logging training status') -parser.add_argument('--device', default='cpu', choices=['cpu', 'gpu'], - help='Whether this is running on cpu or gpu') -parser.add_argument('--num_workers', type=int, default=1, - help='set the number of op workers. only work for gpu') -parser.add_argument('--num_threads', type=int, default=8, - help='set the number of threads per rank. should be 32 (1 rank), 8 (4 rank), 2 (4 rank with async)') -parser.add_argument('--phase_idx', type=int, required=True, - help='which AL phase we are in. This is one-indexed! In other word, AL start with 1, no AL is 0') -parser.add_argument('--data_dir', type=str, default='./', - help='root directory of base/test/validation/study/AL subdir') -parser.add_argument('--shared_file_dir', type=str, required=True, - help='a directory which saves sharedfile for DDP. It must be empty before running this script') -parser.add_argument('--do_preprocess_study', action='store_true', - help='preprocessing the study set here') -parser.add_argument('--do_streaming', action='store_true', - help='Enable streaming mode') +parser = argparse.ArgumentParser(description="Exalearn_Training_v1") + +parser.add_argument( + "--batch_size", + type=int, + default=2048, + help="input batch size for training (default: 2048)", +) +parser.add_argument( + "--epochs", + type=int, + default=1000, + help="number of epochs to train, save the best model instead of the model at last epoch (default: 1000)", +) +parser.add_argument( + "--lr", type=float, default=0.0005, help="learning rate (default: 0.0005)" +) +parser.add_argument("--seed", type=int, default=42, help="random seed (default: 42)") +parser.add_argument( + "--log_interval", + type=int, + default=1, + help="how many batches to wait before logging training status", +) +parser.add_argument( + "--device", + default="cpu", + choices=["cpu", "gpu"], + help="Whether this is running on cpu or gpu", +) +parser.add_argument( + "--num_workers", + type=int, + default=1, + help="set the number of op workers. only work for gpu", +) +parser.add_argument( + "--num_threads", + type=int, + default=8, + help="set the number of threads per rank. should be 32 (1 rank), 8 (4 rank), 2 (4 rank with async)", +) +parser.add_argument( + "--phase_idx", + type=int, + required=True, + help="which AL phase we are in. This is one-indexed! In other word, AL start with 1, no AL is 0", +) +parser.add_argument( + "--data_dir", + type=str, + default="./", + help="root directory of base/test/validation/study/AL subdir", +) +parser.add_argument( + "--shared_file_dir", + type=str, + required=True, + help="a directory which saves sharedfile for DDP. It must be empty before running this script", +) +parser.add_argument( + "--do_preprocess_study", + action="store_true", + help="preprocessing the study set here", +) +parser.add_argument("--do_streaming", action="store_true", help="Enable streaming mode") args = parser.parse_args() -args.cuda = ( args.device.find("gpu")!=-1 and torch.cuda.is_available() ) +args.cuda = args.device.find("gpu") != -1 and torch.cuda.is_available() if args.cuda: torch.cuda.manual_seed(args.seed) @@ -65,71 +98,113 @@ if not args.cuda: torch.use_deterministic_algorithms(True) + def print_memory_usage(info): - util.print_memory_usage_template("train_step2, phase_idx = {}".format(args.phase_idx), info) + util.print_memory_usage_template(f"train_step2, phase_idx = {args.phase_idx}", info) + print_memory_usage("All ranks should report Starting!") -#--------------------DDP initialization------------------------- +# --------------------DDP initialization------------------------- size = int(os.getenv("PMI_SIZE")) rank = int(os.getenv("PMI_RANK")) local_rank = int(os.getenv("PMI_LOCAL_RANK")) -print("DDP: I am worker size = {}, rank = {}, local_rank = {}".format(size, rank, local_rank)) +print(f"DDP: I am worker size = {size}, rank = {rank}, local_rank = {local_rank}") # Pytorch will look for these: os.environ["RANK"] = str(rank) os.environ["WORLD_SIZE"] = str(size) -if args.device == "gpu": backend = 'nccl' -elif args.device == "cpu": backend = 'gloo' +if args.device == "gpu": + backend = "nccl" +elif args.device == "cpu": + backend = "gloo" + def print_from_rank0(*args, **kwargs): if rank == 0: print(*args, **kwargs) + def print_memory_usage_from_rank0(info): if rank == 0: print_memory_usage(info) + print_from_rank0("args = ", args) print_from_rank0("backend = ", backend) -torch.distributed.init_process_group(backend=backend, init_method='file://{}/sharedfile'.format(args.shared_file_dir), world_size=size, rank=rank) -print("Setting process group, is_initialized = {}, nccl_avail = {}, get_rank = {}, get_size = {}".format(torch.distributed.is_initialized(), torch.distributed.is_nccl_available(), torch.distributed.get_rank(), torch.distributed.get_world_size())) +torch.distributed.init_process_group( + backend=backend, + init_method=f"file://{args.shared_file_dir}/sharedfile", + world_size=size, + rank=rank, +) +print( + f"Setting process group, is_initialized = {torch.distributed.is_initialized()}, nccl_avail = {torch.distributed.is_nccl_available()}, get_rank = {torch.distributed.get_rank()}, get_size = {torch.distributed.get_world_size()}" +) if args.cuda: # DDP: pin GPU to local rank. - print("rank = {}, local_rank = {}, num_of_gpus = {}".format(rank, local_rank, torch.cuda.device_count())) + print( + f"rank = {rank}, local_rank = {local_rank}, num_of_gpus = {torch.cuda.device_count()}" + ) # Handles the case where we pinned GPU to local rank in run script if torch.cuda.device_count() == 1: torch.cuda.set_device(0) else: -# torch.cuda.set_device(int(local_rank)) - torch.cuda.set_device(torch.cuda.device_count() - 1 - int(local_rank)) # handles Polaris NUMA topology + # torch.cuda.set_device(int(local_rank)) + torch.cuda.set_device( + torch.cuda.device_count() - 1 - int(local_rank) + ) # handles Polaris NUMA topology -if (not args.cuda) and (args.num_threads!=0): +if (not args.cuda) and (args.num_threads != 0): torch.set_num_threads(args.num_threads) -print("Rank = {}".format(rank), " Torch Thread setup with number of threads: ", torch.get_num_threads(), " with number of inter_op threads: ", torch.get_num_interop_threads()) - -#-----------------------------Loading data-------------------------------- +print( + f"Rank = {rank}", + " Torch Thread setup with number of threads: ", + torch.get_num_threads(), + " with number of inter_op threads: ", + torch.get_num_interop_threads(), +) + +# -----------------------------Loading data-------------------------------- print_memory_usage_from_rank0("Before loading data!") -base_cubic_file = os.path.join(args.data_dir, "base/data/cubic_1001460_cubic.hdf5") -base_trigonal_file = os.path.join(args.data_dir, "base/data/trigonal_1522004_trigonal.hdf5") -base_tetragonal_file = os.path.join(args.data_dir, "base/data/tetragonal_1531431_tetragonal.hdf5") -test_cubic_file = os.path.join(args.data_dir, "test/data/cubic_1001460_cubic.hdf5") -test_trigonal_file = os.path.join(args.data_dir, "test/data/trigonal_1522004_trigonal.hdf5") -test_tetragonal_file = os.path.join(args.data_dir, "test/data/tetragonal_1531431_tetragonal.hdf5") -val_cubic_file = os.path.join(args.data_dir, "validation/data/cubic_1001460_cubic.hdf5") -val_trigonal_file = os.path.join(args.data_dir, "validation/data/trigonal_1522004_trigonal.hdf5") -val_tetragonal_file = os.path.join(args.data_dir, "validation/data/tetragonal_1531431_tetragonal.hdf5") - - -x_train, y_train = util.create_numpy_data(base_cubic_file, base_trigonal_file, base_tetragonal_file) -x_val, y_val = util.create_numpy_data(val_cubic_file, val_trigonal_file, val_tetragonal_file) -x_test, y_test = util.create_numpy_data(test_cubic_file, test_trigonal_file, test_tetragonal_file) +base_cubic_file = os.path.join(args.data_dir, "base/data/cubic_1001460_cubic.hdf5") +base_trigonal_file = os.path.join( + args.data_dir, "base/data/trigonal_1522004_trigonal.hdf5" +) +base_tetragonal_file = os.path.join( + args.data_dir, "base/data/tetragonal_1531431_tetragonal.hdf5" +) +test_cubic_file = os.path.join(args.data_dir, "test/data/cubic_1001460_cubic.hdf5") +test_trigonal_file = os.path.join( + args.data_dir, "test/data/trigonal_1522004_trigonal.hdf5" +) +test_tetragonal_file = os.path.join( + args.data_dir, "test/data/tetragonal_1531431_tetragonal.hdf5" +) +val_cubic_file = os.path.join(args.data_dir, "validation/data/cubic_1001460_cubic.hdf5") +val_trigonal_file = os.path.join( + args.data_dir, "validation/data/trigonal_1522004_trigonal.hdf5" +) +val_tetragonal_file = os.path.join( + args.data_dir, "validation/data/tetragonal_1531431_tetragonal.hdf5" +) + + +x_train, y_train = util.create_numpy_data( + base_cubic_file, base_trigonal_file, base_tetragonal_file +) +x_val, y_val = util.create_numpy_data( + val_cubic_file, val_trigonal_file, val_tetragonal_file +) +x_test, y_test = util.create_numpy_data( + test_cubic_file, test_trigonal_file, test_tetragonal_file +) print_from_rank0("x_train.shape = ", x_train.shape) print_from_rank0("y_train.shape = ", y_train.shape) @@ -140,17 +215,25 @@ def print_memory_usage_from_rank0(info): print_memory_usage_from_rank0("Finish loading data!") -kwargs = {'num_workers': args.num_workers, 'pin_memory': True} if args.cuda else {} +kwargs = {"num_workers": args.num_workers, "pin_memory": True} if args.cuda else {} x_train_torch = torch.from_numpy(x_train).float() -x_train_torch = x_train_torch.reshape((x_train_torch.shape[0], 1, x_train_torch.shape[1])) +x_train_torch = x_train_torch.reshape( + (x_train_torch.shape[0], 1, x_train_torch.shape[1]) +) y_train_torch = torch.from_numpy(y_train).float() if args.phase_idx == 0: train_dataset = torch.utils.data.TensorDataset(x_train_torch, y_train_torch) train_sampler = torch.utils.data.distributed.DistributedSampler( - train_dataset, num_replicas=size, rank=rank, drop_last=True) + train_dataset, num_replicas=size, rank=rank, drop_last=True + ) train_loader = torch.utils.data.DataLoader( - train_dataset, batch_size=args.batch_size, sampler=train_sampler, drop_last=True, **kwargs) + train_dataset, + batch_size=args.batch_size, + sampler=train_sampler, + drop_last=True, + **kwargs, + ) print_from_rank0("x_train_torch.shape = ", x_train_torch.shape) print_from_rank0("y_train_torch.shape = ", y_train_torch.shape) @@ -159,9 +242,15 @@ def print_memory_usage_from_rank0(info): y_test_torch = torch.from_numpy(y_test).float() test_dataset = torch.utils.data.TensorDataset(x_test_torch, y_test_torch) test_sampler = torch.utils.data.distributed.DistributedSampler( - test_dataset, num_replicas=size, rank=rank, drop_last=True) + test_dataset, num_replicas=size, rank=rank, drop_last=True +) test_loader = torch.utils.data.DataLoader( - test_dataset, batch_size=args.batch_size, sampler=test_sampler, drop_last=True, **kwargs) + test_dataset, + batch_size=args.batch_size, + sampler=test_sampler, + drop_last=True, + **kwargs, +) print_from_rank0("x_test_torch.shape = ", x_test_torch.shape) print_from_rank0("y_test_torch.shape = ", y_test_torch.shape) @@ -170,21 +259,37 @@ def print_memory_usage_from_rank0(info): y_val_torch = torch.from_numpy(y_val).float() val_dataset = torch.utils.data.TensorDataset(x_val_torch, y_val_torch) val_sampler = torch.utils.data.distributed.DistributedSampler( - val_dataset, num_replicas=size, rank=rank, drop_last=True) + val_dataset, num_replicas=size, rank=rank, drop_last=True +) val_loader = torch.utils.data.DataLoader( - val_dataset, batch_size=args.batch_size, sampler=val_sampler, drop_last=True, **kwargs) + val_dataset, + batch_size=args.batch_size, + sampler=val_sampler, + drop_last=True, + **kwargs, +) print_from_rank0("x_val_torch.shape = ", x_val_torch.shape) print_from_rank0("y_val_torch.shape = ", y_val_torch.shape) if args.do_preprocess_study: - study_cubic_file = os.path.join(args.data_dir, "study/data/cubic_1001460_cubic.hdf5") - study_trigonal_file = os.path.join(args.data_dir, "study/data/trigonal_1522004_trigonal.hdf5") - study_tetragonal_file = os.path.join(args.data_dir, "study/data/tetragonal_1531431_tetragonal.hdf5") - x_study, y_study = util.create_numpy_data(study_cubic_file, study_trigonal_file, study_tetragonal_file) + study_cubic_file = os.path.join( + args.data_dir, "study/data/cubic_1001460_cubic.hdf5" + ) + study_trigonal_file = os.path.join( + args.data_dir, "study/data/trigonal_1522004_trigonal.hdf5" + ) + study_tetragonal_file = os.path.join( + args.data_dir, "study/data/tetragonal_1531431_tetragonal.hdf5" + ) + x_study, y_study = util.create_numpy_data( + study_cubic_file, study_trigonal_file, study_tetragonal_file + ) print_from_rank0("x_study.shape = ", x_study.shape) print_from_rank0("y_study.shape = ", y_study.shape) x_study_torch = torch.from_numpy(x_study).float() - x_study_torch = x_study_torch.reshape((x_study_torch.shape[0], 1, x_study_torch.shape[1])) + x_study_torch = x_study_torch.reshape( + (x_study_torch.shape[0], 1, x_study_torch.shape[1]) + ) y_study_torch = torch.from_numpy(y_study).float() print_from_rank0("x_study_torch.shape = ", x_study_torch.shape) print_from_rank0("y_study_torch.shape = ", y_study_torch.shape) @@ -192,36 +297,38 @@ def print_memory_usage_from_rank0(info): torch.save(x_study_torch, "x_study_torch.pt") - - - - - print_memory_usage_from_rank0("Finish creating torch dataset and loader!") -#----------------------------setup model--------------------------------- -#Important! FIXME -#Here num_output should be 3+1 instead of 3, since each sample needs one value representing its uncertainty -model = mdp.FullModel(len_input = 2806, num_hidden = 256, num_output = 3+1, num_classes = 3) +# ----------------------------setup model--------------------------------- +# Important! FIXME +# Here num_output should be 3+1 instead of 3, since each sample needs one value representing its uncertainty +model = mdp.FullModel(len_input=2806, num_hidden=256, num_output=3 + 1, num_classes=3) if args.cuda: model = model.cuda() model = DDP(model) print_memory_usage_from_rank0("Finish creating model!") -#---------------------------setup optimizer------------------------ +# ---------------------------setup optimizer------------------------ optimizer = torch.optim.Adam(model.parameters(), lr=args.lr * np.sqrt(size)) + def criterion_reg(y_pred, y_true): y_pred_value = y_pred[:, 0:3].reshape(-1, 3) logsig2 = y_pred[:, 3].reshape(-1, 1) - l2_diff = torch.sum((y_true - y_pred_value) ** 2, axis=1, keepdims=True) / torch.exp(logsig2) + logsig2 + l2_diff = ( + torch.sum((y_true - y_pred_value) ** 2, axis=1, keepdims=True) + / torch.exp(logsig2) + + logsig2 + ) return torch.mean(l2_diff) -#criterion_class = torch.nn.BCEWithLogitsLoss() + +# criterion_class = torch.nn.BCEWithLogitsLoss() criterion_class = torch.nn.CrossEntropyLoss() + def lr_lambda(epoch): if epoch <= 5000: return 1.0 @@ -230,50 +337,84 @@ def lr_lambda(epoch): else: return 0.2 + scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda) print_memory_usage_from_rank0("finish setting up optimizer!") -#------------------------Load possible previous model and extra AL dataset---------------------------- +# ------------------------Load possible previous model and extra AL dataset---------------------------- if args.phase_idx > 0: - checkpoint = torch.load('ckpt.pth') - model.load_state_dict(checkpoint['model_state_dict']) - optimizer.load_state_dict(checkpoint['optimizer_state_dict']) + checkpoint = torch.load("ckpt.pth") + model.load_state_dict(checkpoint["model_state_dict"]) + optimizer.load_state_dict(checkpoint["optimizer_state_dict"]) print_memory_usage_from_rank0("finish loading ckpt from disk") x_AL_list = [] y_AL_list = [] -#In phase_k, we already have base data and AL_1 upto AL_k + # In phase_k, we already have base data and AL_1 upto AL_k for i in range(1, args.phase_idx + 1): - AL_cubic_file = os.path.join(args.data_dir, "AL_phase_{}/data/cubic_1001460_cubic.hdf5".format(i)) - AL_trigonal_file = os.path.join(args.data_dir, "AL_phase_{}/data/trigonal_1522004_trigonal.hdf5".format(i)) - AL_tetragonal_file = os.path.join(args.data_dir, "AL_phase_{}/data/tetragonal_1531431_tetragonal.hdf5".format(i)) - x_AL_temp, y_AL_temp = util.create_numpy_data(AL_cubic_file, AL_trigonal_file, AL_tetragonal_file) + AL_cubic_file = os.path.join( + args.data_dir, f"AL_phase_{i}/data/cubic_1001460_cubic.hdf5" + ) + AL_trigonal_file = os.path.join( + args.data_dir, f"AL_phase_{i}/data/trigonal_1522004_trigonal.hdf5" + ) + AL_tetragonal_file = os.path.join( + args.data_dir, f"AL_phase_{i}/data/tetragonal_1531431_tetragonal.hdf5" + ) + x_AL_temp, y_AL_temp = util.create_numpy_data( + AL_cubic_file, AL_trigonal_file, AL_tetragonal_file + ) x_AL_list.append(x_AL_temp) y_AL_list.append(y_AL_temp) -#In streaming execution, we not only need AL data, but also streaming data stream_1 upto stream_k-1 + # In streaming execution, we not only need AL data, but also streaming data stream_1 upto stream_k-1 if args.do_streaming: for i in range(1, args.phase_idx): - stream_cubic_file = os.path.join(args.data_dir, "stream_phase_{}/data/cubic_1001460_cubic.hdf5".format(i)) - stream_trigonal_file = os.path.join(args.data_dir, "stream_phase_{}/data/trigonal_1522004_trigonal.hdf5".format(i)) - stream_tetragonal_file = os.path.join(args.data_dir, "stream_phase_{}/data/tetragonal_1531431_tetragonal.hdf5".format(i)) - x_AL_temp, y_AL_temp = util.create_numpy_data(stream_cubic_file, stream_trigonal_file, stream_tetragonal_file) + stream_cubic_file = os.path.join( + args.data_dir, f"stream_phase_{i}/data/cubic_1001460_cubic.hdf5" + ) + stream_trigonal_file = os.path.join( + args.data_dir, f"stream_phase_{i}/data/trigonal_1522004_trigonal.hdf5" + ) + stream_tetragonal_file = os.path.join( + args.data_dir, + f"stream_phase_{i}/data/tetragonal_1531431_tetragonal.hdf5", + ) + x_AL_temp, y_AL_temp = util.create_numpy_data( + stream_cubic_file, stream_trigonal_file, stream_tetragonal_file + ) x_AL_list.append(x_AL_temp) y_AL_list.append(y_AL_temp) for i in range(len(x_AL_list)): - x_train_torch = torch.cat((x_train_torch, torch.from_numpy(x_AL_list[i]).float().reshape((x_AL_list[i].shape[0], 1, x_AL_list[i].shape[1]))), axis=0) - y_train_torch = torch.cat((y_train_torch, torch.from_numpy(y_AL_list[i]).float()), axis=0) - + x_train_torch = torch.cat( + ( + x_train_torch, + torch.from_numpy(x_AL_list[i]) + .float() + .reshape((x_AL_list[i].shape[0], 1, x_AL_list[i].shape[1])), + ), + axis=0, + ) + y_train_torch = torch.cat( + (y_train_torch, torch.from_numpy(y_AL_list[i]).float()), axis=0 + ) + train_dataset = torch.utils.data.TensorDataset(x_train_torch, y_train_torch) train_sampler = torch.utils.data.distributed.DistributedSampler( - train_dataset, num_replicas=size, rank=rank, drop_last=True) + train_dataset, num_replicas=size, rank=rank, drop_last=True + ) train_loader = torch.utils.data.DataLoader( - train_dataset, batch_size=args.batch_size, sampler=train_sampler, drop_last=True, **kwargs) + train_dataset, + batch_size=args.batch_size, + sampler=train_sampler, + drop_last=True, + **kwargs, + ) print_from_rank0("x_train_torch.shape = ", x_train_torch.shape) print_from_rank0("y_train_torch.shape = ", y_train_torch.shape) @@ -281,12 +422,14 @@ def lr_lambda(epoch): print_memory_usage_from_rank0("finish loading AL data") for param_group in optimizer.param_groups: - param_group['lr'] = args.lr + param_group["lr"] = args.lr scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda) -print_from_rank0("train script, before real train takes {}".format(time.time() - script_start_time)) +print_from_rank0( + f"train script, before real train takes {time.time() - script_start_time}" +) -#------------------------------start training---------------------------------- +# ------------------------------start training---------------------------------- train_loss_list = [] val_loss_list = [] @@ -297,70 +440,83 @@ def lr_lambda(epoch): best_checkpoint = None for epoch in range(0, args.epochs): - epoch_time_tot = time.time() - ker.train(epoch, rank, size, - model = model, - optimizer = optimizer, - train_loader = train_loader, - train_sampler = train_sampler, - criterion_reg = criterion_reg, criterion_class = criterion_class, - lr_scheduler = scheduler, - on_gpu = args.cuda, - log_interval = args.log_interval, - loss_list = train_loss_list) + ker.train( + epoch, + rank, + size, + model=model, + optimizer=optimizer, + train_loader=train_loader, + train_sampler=train_sampler, + criterion_reg=criterion_reg, + criterion_class=criterion_class, + lr_scheduler=scheduler, + on_gpu=args.cuda, + log_interval=args.log_interval, + loss_list=train_loss_list, + ) epoch_time = time.time() - print_from_rank0("epoch {}, train takes {}".format(epoch, epoch_time - epoch_time_tot)) - - val_loss = ker.test(epoch, rank, size, - model = model, - test_loader = val_loader, - criterion_reg = criterion_reg, criterion_class = criterion_class, - on_gpu = args.cuda, - log_interval = args.log_interval, - loss_list = val_loss_list) + print_from_rank0(f"epoch {epoch}, train takes {epoch_time - epoch_time_tot}") + + val_loss = ker.test( + epoch, + rank, + size, + model=model, + test_loader=val_loader, + criterion_reg=criterion_reg, + criterion_class=criterion_class, + on_gpu=args.cuda, + log_interval=args.log_interval, + loss_list=val_loss_list, + ) epoch_time = time.time() - epoch_time - print_from_rank0("epoch {}, validation takes {}".format(epoch, epoch_time)) + print_from_rank0(f"epoch {epoch}, validation takes {epoch_time}") if val_loss < best_loss: best_loss = val_loss best_epoch = epoch if rank == 0: checkpoint = { - 'model_state_dict': model.state_dict(), - 'optimizer_state_dict': optimizer.state_dict(), + "model_state_dict": model.state_dict(), + "optimizer_state_dict": optimizer.state_dict(), } best_checkpoint = copy.deepcopy(checkpoint) print_from_rank0("Better model at epoch ", epoch) - print_from_rank0("epoch {} takes {}".format(epoch, time.time() - epoch_time_tot)) + print_from_rank0(f"epoch {epoch} takes {time.time() - epoch_time_tot}") if rank == 0: - torch.save(best_checkpoint, 'ckpt.pth') -print_from_rank0("Best val loss = {} at epoch = {}".format(best_loss, best_epoch)) + torch.save(best_checkpoint, "ckpt.pth") +print_from_rank0(f"Best val loss = {best_loss} at epoch = {best_epoch}") time_real_train = time.time() - time_real_train -print_from_rank0("Total training time = {}".format(time_real_train)) +print_from_rank0(f"Total training time = {time_real_train}") print_memory_usage_from_rank0("finish first training part") st = time.time() -model = mdp.FullModel(len_input = 2806, num_hidden = 256, num_output = 3+1, num_classes = 3) +model = mdp.FullModel(len_input=2806, num_hidden=256, num_output=3 + 1, num_classes=3) if args.cuda: model = model.cuda() model = DDP(model) -checkpoint = torch.load('ckpt.pth') -model.load_state_dict(checkpoint['model_state_dict']) +checkpoint = torch.load("ckpt.pth") +model.load_state_dict(checkpoint["model_state_dict"]) criterion_l2 = torch.nn.MSELoss() print_memory_usage_from_rank0("finish loading from disk for testing") -l2_diff, sigma2, class_loss = ker.validation(rank, size, - model = model, - test_loader = test_loader, - criterion_reg = criterion_l2, criterion_class = criterion_class, - on_gpu = args.cuda) - -print_from_rank0("Final testing time = {}".format(time.time() - st)) +l2_diff, sigma2, class_loss = ker.validation( + rank, + size, + model=model, + test_loader=test_loader, + criterion_reg=criterion_l2, + criterion_class=criterion_class, + on_gpu=args.cuda, +) + +print_from_rank0(f"Final testing time = {time.time() - st}") torch.distributed.destroy_process_group() diff --git a/examples/use_cases/neutron-scattering/scripts/util.py b/examples/use_cases/neutron-scattering/scripts/util.py index b4141a9a..5d10c15b 100644 --- a/examples/use_cases/neutron-scattering/scripts/util.py +++ b/examples/use_cases/neutron-scattering/scripts/util.py @@ -1,57 +1,61 @@ import h5py import numpy as np -from sklearn.preprocessing import MinMaxScaler -import torch import psutil +import torch +from sklearn.preprocessing import MinMaxScaler + def print_memory_usage_template(caller, info): print(caller, " logging: ", info) print(torch.cuda.memory_summary()) memory_info = psutil.virtual_memory() - print(f"CPU Memory Usage: {memory_info.used / (1024 ** 3):.2f} GB / {memory_info.total / (1024 ** 3):.2f} GB") + print( + f"CPU Memory Usage: {memory_info.used / (1024**3):.2f} GB / {memory_info.total / (1024**3):.2f} GB" + ) def create_numpy_data(file_cubic, file_trigonal, file_tetragonal): - with h5py.File(file_cubic, 'r') as f: - dhisto = f['histograms'] + with h5py.File(file_cubic, "r") as f: + dhisto = f["histograms"] x_cubic = dhisto[:, 1, :] x_shape = x_cubic.shape - dparams = f['parameters'] + dparams = f["parameters"] y_cubic = dparams[:] y_shape = y_cubic.shape print(x_shape) print(y_shape) - with h5py.File(file_trigonal, 'r') as f: - dhisto = f['histograms'] + with h5py.File(file_trigonal, "r") as f: + dhisto = f["histograms"] x_trigonal = dhisto[:, 1, :] x_shape = x_trigonal.shape - dparams = f['parameters'] + dparams = f["parameters"] y_trigonal = dparams[:] y_shape = y_trigonal.shape print(x_shape) print(y_shape) - with h5py.File(file_tetragonal, 'r') as f: - dhisto = f['histograms'] + with h5py.File(file_tetragonal, "r") as f: + dhisto = f["histograms"] x_tetragonal = dhisto[:, 1, :] x_shape = x_tetragonal.shape - dparams = f['parameters'] + dparams = f["parameters"] y_tetragonal = dparams[:] y_shape = y_tetragonal.shape print(x_shape) print(y_shape) - + x = np.concatenate([x_cubic, x_trigonal, x_tetragonal], axis=0) scaler_x = MinMaxScaler(copy=True) x = scaler_x.fit_transform(x.T).T y = np.concatenate([y_cubic, y_trigonal, y_tetragonal], axis=0) - y[:,0] = (y[:,0] - 3.5 ) / 1.0 - y[:,1] = (y[:,1] - 3.5 ) / 1.0 - y[:,2] = (y[:,2] - 30.0 ) / 90.0 + y[:, 0] = (y[:, 0] - 3.5) / 1.0 + y[:, 1] = (y[:, 1] - 3.5) / 1.0 + y[:, 2] = (y[:, 2] - 30.0) / 90.0 return x, y + class EarlyStopping: def __init__(self, max_num, min_delta): self.max_num = max_num diff --git a/mkdocs.yml b/mkdocs.yml index 809018b5..c35fd6a4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -47,7 +47,7 @@ theme: font: text: 'Segoe UI' code: Roboto Mono - + #logo: assets/rose-removebg-preview.png @@ -94,8 +94,10 @@ nav: - 7.Reinforcement Learning: user-guide/basic-rl-workflow.md - 8.Experience Banks: user-guide/experience.md - 9.Advanced RL workflow: user-guide/advanced-rl-workflow.md + - 10.Tracking & Observability: user-guide/tracking.md - Integrations: - MLflow: integrations/mlflow.md + - ClearML: integrations/clearml.md - Changelog: changelog.md extra_css: diff --git a/pyproject.toml b/pyproject.toml index a1be67c8..266342c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ROSE" -version = "0.1.0" +version = "0.3.0" description = "Toolkit to express and execute ML surrogate building workflows on HPC" authors = [ @@ -15,9 +15,15 @@ maintainers = [ {name = "Aymen Alsaadi", email = "aymen.alsaadi@rutgers.edu"}, ] readme = "README.md" -requires-python = ">=3.9" - -dependencies = ["numpy", "radical.asyncflow", "radical.pilot", "PyYAML"] +requires-python = ">=3.10" + +dependencies = [ + "numpy", + "PyYAML", + "radical.asyncflow", + "rhapsody-py[radical_pilot]", + 'rhapsody-py[dragon]; python_version >= "3.10" and python_version <= "3.12"', +] [project.urls] Homepage = "https://github.com/radical-cybertools/ROSE" @@ -33,11 +39,16 @@ rose = "rose.service.api.rest:PluginRose" [project.optional-dependencies] lint = ["ruff"] +mlflow = ["mlflow>=2.0"] +clearml = ["clearml>=1.14"] +tracking = ["mlflow>=2.0", "clearml>=1.14"] + # All test deps dev = [ "pytest", "pytest-asyncio", - "pytest-cov" + "pytest-cov", + "pre-commit", ] doc = [ @@ -52,14 +63,26 @@ doc = [ "mkdocs-minify-plugin" ] +[tool.setuptools.packages.find] +include = ["rose*"] + [tool.ruff] -line-length = 88 -target-version = "py39" +line-length = 100 +target-version = "py310" fix = true +exclude = ["examples/use_cases/"] [tool.ruff.lint] select = ["E", "F", "W", "B", "I", "N", "UP"] -ignore = ["UP007", "UP045"] +# N803/N806: uppercase variable/arg names are standard ML convention (X, X_labeled, etc.) +# N801: allow non-CapWords class names for ML acronyms (MC_Dropout_CNN, etc.) +# N812/N813/N816/N817: allow ML import aliases (functional as F, etc.) +ignore = ["N803", "N806", "N801", "N812", "N813", "N816", "N817"] + +[tool.ruff.lint.per-file-ignores] +# task_description={"shell": True} is a ROSE API pattern introspected by decorators +"examples/**/run_me.py" = ["B006"] +"examples/**/run_me_*.py" = ["B006"] [tool.ruff.format] quote-style = "double" diff --git a/rose/__init__.py b/rose/__init__.py index 5e377c09..0038267b 100644 --- a/rose/__init__.py +++ b/rose/__init__.py @@ -2,6 +2,7 @@ from rose.learner import IterationState, Learner, LearnerConfig, TaskConfig from rose.metrics import * # noqa: F403 from rose.rl import reinforcement_learner +from rose.tracking import PipelineManifest, TrackerBase from rose.uq import uq_active_learner, uq_learner, uq_scorer __all__ = [ @@ -17,4 +18,7 @@ "LearnerConfig", "TaskConfig", "IterationState", + # Tracking + "TrackerBase", + "PipelineManifest", ] diff --git a/rose/al/active_learner.py b/rose/al/active_learner.py index 0aa6a2db..bea6b97b 100644 --- a/rose/al/active_learner.py +++ b/rose/al/active_learner.py @@ -1,15 +1,13 @@ import asyncio +import dataclasses import itertools -import logging import warnings -from collections.abc import AsyncIterator, Coroutine, Iterator -from typing import Any, Optional, Union +from collections.abc import AsyncIterator, Iterator +from typing import Any from radical.asyncflow import WorkflowEngine -from ..learner import IterationState, Learner, LearnerConfig - -logger = logging.getLogger(__name__) +from ..learner import IterationState, Learner, LearnerConfig, _stream_parallel class SequentialActiveLearner(Learner): @@ -53,18 +51,18 @@ def __init__(self, asyncflow: WorkflowEngine) -> None: asyncflow: The workflow engine instance used to manage async tasks. """ super().__init__(asyncflow, register_and_submit=True) - self.learner_id: Optional[int] = None + self.learner_id: int | None = None - self._iteration_state: Optional[IterationState] = None - self._pending_config: Optional[LearnerConfig] = None - self._max_iter: Optional[int] = None + self._iteration_state: IterationState | None = None + self._pending_config: LearnerConfig | None = None + self._max_iter: int | None = None async def start( self, max_iter: int = 0, skip_pre_loop: bool = False, skip_simulation_step: bool = False, - initial_config: Optional[LearnerConfig] = None, + initial_config: LearnerConfig | None = None, ) -> AsyncIterator[IterationState]: """Start the learner and yield state at each iteration. @@ -111,23 +109,18 @@ async def start( """ # Validation if not skip_simulation_step and not self.simulation_function: - raise ValueError( - "Simulation function must be set when not using simulation pool!" - ) + raise ValueError("Simulation function must be set when not using simulation pool!") if not self.training_function or not self.active_learn_function: raise ValueError("Training and Active Learning functions must be set!") if max_iter == 0 and not self.criterion_function: - raise ValueError( - "Either max_iter > 0 or criterion_function must be provided." - ) + raise ValueError("Either max_iter > 0 or criterion_function must be provided.") self._max_iter = max_iter if max_iter > 0 else None learner_config = initial_config + _stop_reason = "max_iter_reached" - learner_suffix = ( - f" (Learner-{self.learner_id})" if self.learner_id is not None else "" - ) - logger.info(f"Starting Active Learner{learner_suffix}") + learner_suffix = f" (Learner-{self.learner_id})" if self.learner_id is not None else "" + print(f"Starting Active Learner{learner_suffix}") # Initialize task references sim_task: Any = () @@ -159,110 +152,123 @@ async def start( train_result = await train_task # Determine iteration range - iteration_range: Union[Iterator[int], range] + iteration_range: Iterator[int] | range if max_iter == 0: iteration_range = itertools.count() else: iteration_range = range(max_iter) # Main iteration loop - for i in iteration_range: - learner_prefix = ( - f"[Learner-{self.learner_id}] " if self.learner_id is not None else "" - ) - if self.is_stopped: - logger.info(f"{learner_prefix}Stop requested, exiting learning loop.") - break - - # Check for pending config - if self._pending_config is not None: - learner_config = self._pending_config - self._pending_config = None - - # Clear transient state from previous iteration - self.clear_state() - - # Extract state from sim/train results - # (prepared in previous iteration or pre-loop) - if not skip_simulation_step and sim_result is not None: - self._extract_state_from_result(sim_result) - if train_result is not None: - self._extract_state_from_result(train_result) - - logger.info(f"{learner_prefix}Starting Iteration-{i}") - - # Get iteration-specific AL config - acl_config = self._get_iteration_task_config( - self.active_learn_function, learner_config, "active_learn", i - ) + try: + for i in iteration_range: + learner_prefix = ( + f"[Learner-{self.learner_id}] " if self.learner_id is not None else "" + ) + if self.is_stopped: + print(f"{learner_prefix}Stop requested, exiting learning loop.") + _stop_reason = "stopped" + break - # Register AL task with dependencies - if skip_simulation_step: - acl_task = self._register_task(acl_config, deps=train_task) - else: - acl_task = self._register_task(acl_config, deps=(sim_task, train_task)) + # Check for pending config + if self._pending_config is not None: + learner_config = self._pending_config + self._pending_config = None + + # Clear transient state from previous iteration + self.clear_state() - # Await AL task and extract state from dict result - acl_result = await acl_task - if self.is_stopped: - break - self._extract_state_from_result(acl_result) + # Extract state from sim/train results + # (prepared in previous iteration or pre-loop) + if not skip_simulation_step and sim_result is not None: + self._extract_state_from_result(sim_result) + if train_result is not None: + self._extract_state_from_result(train_result) - # Check stop criterion if configured - metric_value: Optional[float] = None - should_stop = False + print(f"{learner_prefix}Starting Iteration-{i}") - if self.criterion_function: - criterion_config = self._get_iteration_task_config( - self.criterion_function, learner_config, "criterion", i + # Get iteration-specific AL config + acl_config = self._get_iteration_task_config( + self.active_learn_function, learner_config, "active_learn", i ) - stop_task = self._register_task(criterion_config) - stop_result = await stop_task + + # Register AL task with dependencies + if skip_simulation_step: + acl_task = self._register_task(acl_config, deps=train_task) + else: + acl_task = self._register_task(acl_config, deps=(sim_task, train_task)) + + # Await AL task and extract state from dict result + acl_result = await acl_task if self.is_stopped: + _stop_reason = "stopped" break - should_stop, metric_value = self._check_stop_criterion(stop_result) - - # Build iteration state - self._iteration_state = self.build_iteration_state( - iteration=i, - metric_value=metric_value, - should_stop=should_stop, - current_config=learner_config, - ) + self._extract_state_from_result(acl_result) - # YIELD CONTROL TO AGENT - yield self._iteration_state + # Check stop criterion if configured + metric_value: float | None = None + should_stop = False - # Check if user loop broke or criterion met - if should_stop: - break + if self.criterion_function: + criterion_config = self._get_iteration_task_config( + self.criterion_function, learner_config, "criterion", i + ) + stop_task = self._register_task(criterion_config) + stop_result = await stop_task + if self.is_stopped: + _stop_reason = "stopped" + break + should_stop, metric_value = self._check_stop_criterion(stop_result) + + # Build iteration state + self._iteration_state = self.build_iteration_state( + iteration=i, + metric_value=metric_value, + should_stop=should_stop, + current_config=learner_config, + ) - # Prepare next iteration using potentially updated config - next_config = self._pending_config or learner_config - next_train_config = self._get_iteration_task_config( - self.training_function, next_config, "training", i + 1 - ) + # Notify trackers then yield control to caller + self._notify_trackers_iteration(self._iteration_state) + yield self._iteration_state - if skip_simulation_step: - sim_task = () - sim_result = None - train_task = self._register_task(next_train_config, deps=acl_task) - else: - next_sim_config = self._get_iteration_task_config( - self.simulation_function, next_config, "simulation", i + 1 + # Check if user loop broke or criterion met + if should_stop: + _stop_reason = "criterion_met" + break + + # Prepare next iteration using potentially updated config + next_config = self._pending_config or learner_config + next_train_config = self._get_iteration_task_config( + self.training_function, next_config, "training", i + 1 ) - sim_task = self._register_task(next_sim_config, deps=acl_task) - train_task = self._register_task(next_train_config, deps=sim_task) - # Await simulation result (extract state in next iteration) - sim_result = await sim_task + if skip_simulation_step: + sim_task = () + sim_result = None + train_task = self._register_task(next_train_config, deps=acl_task) + else: + next_sim_config = self._get_iteration_task_config( + self.simulation_function, next_config, "simulation", i + 1 + ) + sim_task = self._register_task(next_sim_config, deps=acl_task) + train_task = self._register_task(next_train_config, deps=sim_task) + + # Await simulation result (extract state in next iteration) + sim_result = await sim_task + if self.is_stopped: + _stop_reason = "stopped" + break + + # Await training result (extract state in next iteration) + train_result = await train_task if self.is_stopped: + _stop_reason = "stopped" break - - # Await training result (extract state in next iteration) - train_result = await train_task - if self.is_stopped: - break + except Exception: + _stop_reason = "error" + raise + finally: + self._notify_trackers_stop(self._iteration_state, _stop_reason) def set_next_config(self, config: LearnerConfig) -> None: """Set configuration for the next iteration. @@ -285,7 +291,7 @@ def set_next_config(self, config: LearnerConfig) -> None: """ self._pending_config = config - def get_current_state(self) -> Optional[IterationState]: + def get_current_state(self) -> IterationState | None: """Get the current iteration state. Returns: @@ -293,7 +299,7 @@ def get_current_state(self) -> Optional[IterationState]: """ return self._iteration_state - def get_max_iterations(self) -> Optional[int]: + def get_max_iterations(self) -> int | None: """Get the maximum iterations configured for current run. Returns: @@ -306,8 +312,8 @@ async def teach( max_iter: int = 0, skip_pre_loop: bool = False, skip_simulation_step: bool = False, - learner_config: Optional[LearnerConfig] = None, - ) -> Optional[IterationState]: + learner_config: LearnerConfig | None = None, + ) -> IterationState | None: """Run active learning loop to completion. .. deprecated:: @@ -342,17 +348,14 @@ async def teach( class ParallelActiveLearner(Learner): - """Parallel active learner that runs multiple - SequentialActiveLearners concurrently. + """Parallel active learner that runs multiple SequentialActiveLearners concurrently. - This class orchestrates multiple SequentialActiveLearner - instances to run in parallel, allowing for concurrent exploration - of the learning space. Each learner can be configured independently + This class orchestrates multiple SequentialActiveLearner instances to run in parallel, allowing + for concurrent exploration of the learning space. Each learner can be configured independently through per-learner LearnerConfig objects. - The parallel learner manages the lifecycle of all sequential - learners and collects their results when all have completed their - learning processes. + The parallel learner manages the lifecycle of all sequential learners and collects their results + when all have completed their learning processes. """ def __init__(self, asyncflow: WorkflowEngine) -> None: @@ -365,7 +368,7 @@ def __init__(self, asyncflow: WorkflowEngine) -> None: super().__init__(asyncflow, register_and_submit=False) def _create_sequential_learner( - self, learner_id: int, config: Optional[LearnerConfig] + self, learner_id: int, config: LearnerConfig | None ) -> SequentialActiveLearner: """Create a SequentialActiveLearner instance for a parallel learner. @@ -384,9 +387,7 @@ def _create_sequential_learner( independently in the parallel learning environment. """ # Create a new sequential learner with the same asyncflow - sequential_learner: SequentialActiveLearner = SequentialActiveLearner( - self.asyncflow - ) + sequential_learner: SequentialActiveLearner = SequentialActiveLearner(self.asyncflow) # Copy the base functions from the parent learner sequential_learner.simulation_function = self.simulation_function @@ -400,8 +401,8 @@ def _create_sequential_learner( return sequential_learner def _convert_to_sequential_config( - self, parallel_config: Optional[LearnerConfig] - ) -> Optional[LearnerConfig]: + self, parallel_config: LearnerConfig | None + ) -> LearnerConfig | None: """Convert a LearnerConfig to a LearnerConfig. Note: This method currently performs a direct copy as both parallel and @@ -435,13 +436,16 @@ async def start( max_iter: int = 0, skip_pre_loop: bool = False, skip_simulation_step: bool = False, - learner_configs: Optional[list[Optional[LearnerConfig]]] = None, - ) -> list[Any]: + learner_configs: list[LearnerConfig | None] | None = None, + ) -> AsyncIterator[IterationState]: """Run parallel active learning by launching multiple SequentialActiveLearners. Orchestrates multiple SequentialActiveLearner instances to run concurrently, - each with potentially different configurations. All learners run - independently and their results are collected when all have completed. + each with potentially different configurations. States are streamed in real + time as each learner completes an iteration — use ``async for`` to consume them. + + Each yielded ``IterationState`` includes a ``learner_id`` field (int) indicating + which parallel learner produced it. Args: parallel_learners: Number of parallel learners to run concurrently. @@ -456,13 +460,19 @@ async def start( skip_simulation_step: if True, all learners will skip the simulation step and the learner will consider a simulation pool already exist. - Returns: - list containing the final IterationState from each learner, in the - same order as the learners were launched. + Yields: + IterationState for each iteration of each learner, in arrival order. + Each state has ``learner_id`` set to the integer index of the learner. Raises: ValueError: If parallel_learners < 2 (use SequentialActiveLearner instead). ValueError: If learner_configs length doesn't match parallel_learners. + Exception: Re-raises any exception from a learner after all learners finish. + + Example:: + + async for state in learner.start(parallel_learners=3, max_iter=10): + print(f"Learner {state.learner_id}, iter {state.iteration}: {state.metric_value}") """ if parallel_learners < 2: raise ValueError("For single learner, use SequentialActiveLearner") @@ -472,65 +482,47 @@ async def start( if len(learner_configs) != parallel_learners: raise ValueError("learner_configs length must match parallel_learners") - async def active_learner_workflow(learner_id: int) -> Any: - """Run a single SequentialActiveLearner. - - Internal async function that manages the lifecycle of a single - SequentialActiveLearner within the parallel learning context. - - Args: - learner_id: Unique identifier for this learner instance. - Returns: - The final IterationState from the sequential learner. - - Raises: - Exception: Re-raises any exception from the sequential learner - with additional context about which learner failed. - """ - try: - # Create and configure the sequential learner - sequential_learner: SequentialActiveLearner = ( - self._create_sequential_learner( + print(f"Starting Parallel Active Learning with {parallel_learners} learners") + + # Factory required: plain closure in a loop would capture the same variable reference. + def make_run_fn(learner_id: int): + async def run_learner(queue: asyncio.Queue) -> None: + try: + sequential_learner: SequentialActiveLearner = self._create_sequential_learner( learner_id, learner_configs[learner_id] ) - ) - - # Convert parallel config to sequential config - sequential_config: Optional[LearnerConfig] = ( - self._convert_to_sequential_config(learner_configs[learner_id]) - ) - - # Run the sequential learner by iterating through start() - final_state = None - async for state in sequential_learner.start( - max_iter=max_iter, - skip_pre_loop=skip_pre_loop, - skip_simulation_step=skip_simulation_step, - initial_config=sequential_config, - ): - final_state = state - if self.is_stopped: - sequential_learner.stop() - - # book keep the iteration value from each learner - self.metric_values_per_iteration[f"learner-{learner_id}"] = ( - sequential_learner.metric_values_per_iteration - ) - - return final_state - except Exception as e: - logger.error(f"ActiveLearner-{learner_id}] failed with error: {e}") - raise - - logger.info(f"Starting Parallel Active Learning with {parallel_learners} learners") - - # Submit all learners asynchronously - learners: list[Coroutine] = [ - active_learner_workflow(i) for i in range(parallel_learners) - ] - - # Wait for all learners to complete and collect results - return await asyncio.gather(*learners) + async for state in sequential_learner.start( + max_iter=max_iter, + skip_pre_loop=skip_pre_loop, + skip_simulation_step=skip_simulation_step, + initial_config=learner_configs[learner_id], + ): + if self.is_stopped: + sequential_learner.stop() + await queue.put( + ("state", dataclasses.replace(state, learner_id=learner_id)) + ) + self.metric_values_per_iteration[f"learner-{learner_id}"] = ( + sequential_learner.metric_values_per_iteration + ) + except Exception as e: + print(f"[ActiveLearner-{learner_id}] failed with error: {e}") + await queue.put(("error", e)) + finally: + await queue.put(("done", None)) + + return run_learner + + _stop_reason = "max_iter_reached" + try: + async for state in _stream_parallel([make_run_fn(i) for i in range(parallel_learners)]): + self._notify_trackers_iteration(state) + yield state + except Exception: + _stop_reason = "error" + raise + finally: + self._notify_trackers_stop(self._iteration_state, _stop_reason) async def teach( self, @@ -538,7 +530,7 @@ async def teach( max_iter: int = 0, skip_pre_loop: bool = False, skip_simulation_step: bool = False, - learner_configs: Optional[list[Optional[LearnerConfig]]] = None, + learner_configs: list[LearnerConfig | None] | None = None, ) -> list[Any]: """Run parallel active learning loop to completion. @@ -557,15 +549,17 @@ async def teach( List of final IterationState from each learner. """ warnings.warn( - "teach() is deprecated and will be removed in a future version. " - "Use start() instead.", + "teach() is deprecated and will be removed in a future version. Use start() instead.", DeprecationWarning, stacklevel=2, ) - return await self.start( + final_states: dict[int, IterationState | None] = {} + async for state in self.start( parallel_learners=parallel_learners, max_iter=max_iter, skip_pre_loop=skip_pre_loop, skip_simulation_step=skip_simulation_step, learner_configs=learner_configs, - ) + ): + final_states[state.learner_id] = state + return [final_states.get(i) for i in range(parallel_learners)] diff --git a/rose/al/selector.py b/rose/al/selector.py index ac7bc12e..bed0073f 100644 --- a/rose/al/selector.py +++ b/rose/al/selector.py @@ -1,16 +1,13 @@ import asyncio import itertools -import logging -from collections.abc import Iterator -from typing import Any, Callable, Optional, Union +from collections.abc import Callable, Iterator +from typing import Any from radical.asyncflow import WorkflowEngine from ..learner import Learner, LearnerConfig, TaskConfig from .active_learner import SequentialActiveLearner -logger = logging.getLogger(__name__) - class AlgorithmSelector(Learner): """AlgorithmSelector runs multiple active learning algorithms in parallel. @@ -44,16 +41,12 @@ def __init__(self, asyncflow: WorkflowEngine) -> None: # e.g. self.algorithm_results['algo_1'] = \ # {'iterations': 5, 'last_result': 0.01} self.algorithm_results: dict[str, dict[str, Any]] = {} - self.best_pipeline_name: Optional[str] = None - self.best_pipeline_stats: Optional[dict[str, Any]] = None + self.best_pipeline_name: str | None = None + self.best_pipeline_stats: dict[str, Any] | None = None - self.active_learn_task: Callable[[str], Callable] = ( - self._algorithm_active_learn_task - ) + self.active_learn_task: Callable[[str], Callable] = self._algorithm_active_learn_task - def _algorithm_active_learn_task( - self, **decor_kwargs - ) -> Callable[[Callable], Callable]: + def _algorithm_active_learn_task(self, **decor_kwargs) -> Callable[[Callable], Callable]: """Create a decorator for registering active learning algorithms. Args: @@ -91,7 +84,7 @@ def _create_algorithm_learner( self, algorithm_name: str, algorithm_func: Callable, - config: Optional[LearnerConfig], + config: LearnerConfig | None, ) -> "SequentialActiveLearner": """Create a SequentialActiveLearner instance for a specific algorithm. @@ -107,9 +100,7 @@ def _create_algorithm_learner( from rose.al import SequentialActiveLearner # Create a new sequential learner with the same asyncflow - sequential_learner: SequentialActiveLearner = SequentialActiveLearner( - self.asyncflow - ) + sequential_learner: SequentialActiveLearner = SequentialActiveLearner(self.asyncflow) # Copy the base functions from the parent learner sequential_learner.simulation_function = self.simulation_function @@ -126,7 +117,7 @@ async def start( self, max_iter: int = 0, skip_pre_loop: bool = False, - algorithm_configs: Optional[dict[str, LearnerConfig]] = None, + algorithm_configs: dict[str, LearnerConfig] | None = None, ) -> dict[str, Any]: """Run multiple active learning algorithms in parallel and select the best. @@ -155,34 +146,27 @@ async def start( or not self.training_function or not self.active_learn_functions ): - raise Exception( - "Simulation, Training, and at least one AL function must be set!" - ) + raise Exception("Simulation, Training, and at least one AL function must be set!") if not max_iter and not self.criterion_function: - raise Exception( - "Either max_iter or stop_criterion_function must be provided." - ) + raise Exception("Either max_iter or stop_criterion_function must be provided.") if not self.active_learn_functions: raise Exception( - "No active learning algorithms registered! " - "Use @active_learn_task decorator." + "No active learning algorithms registered! Use @active_learn_task decorator." ) # Initialize algorithm configs if not provided algorithm_configs = algorithm_configs or {} - logger.info( + print( f"Starting algorithm selection with " f"{len(self.active_learn_functions)} algorithms: " f"{list(self.active_learn_functions.keys())}" ) @self.asyncflow.block - async def _run_algorithm_pipeline( - al_name: str, al_task: dict - ) -> dict[str, Any]: + async def _run_algorithm_pipeline(al_name: str, al_task: dict) -> dict[str, Any]: """Run a single algorithm pipeline. Args: @@ -201,16 +185,12 @@ async def _run_algorithm_pipeline( algorithm_func = al_task["func"] # Create and configure the sequential learner for this algorithm - algorithm_config: Optional[LearnerConfig] = algorithm_configs.get( - algorithm_name, None - ) - sequential_learner: SequentialActiveLearner = ( - self._create_algorithm_learner( - algorithm_name, algorithm_func, algorithm_config - ) + algorithm_config: LearnerConfig | None = algorithm_configs.get(algorithm_name, None) + sequential_learner: SequentialActiveLearner = self._create_algorithm_learner( + algorithm_name, algorithm_func, algorithm_config ) - logger.info(f"[Algorithm-{algorithm_name}] Starting pipeline") + print(f"[Algorithm-{algorithm_name}] Starting pipeline") # Track iterations and results for this algorithm iteration_count: int = 0 @@ -222,30 +202,24 @@ async def _run_algorithm_pipeline( if not skip_pre_loop: # Pre-loop: use iteration 0 configuration - sim_config: TaskConfig = ( - sequential_learner._get_iteration_task_config( - sequential_learner.simulation_function, - algorithm_config, - "simulation", - 0, - ) + sim_config: TaskConfig = sequential_learner._get_iteration_task_config( + sequential_learner.simulation_function, + algorithm_config, + "simulation", + 0, ) - train_config: TaskConfig = ( - sequential_learner._get_iteration_task_config( - sequential_learner.training_function, - algorithm_config, - "training", - 0, - ) + train_config: TaskConfig = sequential_learner._get_iteration_task_config( + sequential_learner.training_function, + algorithm_config, + "training", + 0, ) sim_task = sequential_learner._register_task(sim_config) - train_task = sequential_learner._register_task( - train_config, deps=sim_task - ) + train_task = sequential_learner._register_task(train_config, deps=sim_task) # Determine iteration range - iteration_range: Union[Iterator[int], range] + iteration_range: Iterator[int] | range if not max_iter: iteration_range = itertools.count() else: @@ -253,13 +227,11 @@ async def _run_algorithm_pipeline( # Main learning loop for i in iteration_range: - logger.info(f"[Algorithm-{algorithm_name}] Starting Iteration-{i}") + print(f"[Algorithm-{algorithm_name}] Starting Iteration-{i}") # Get iteration-specific configurations - acl_config: TaskConfig = ( - sequential_learner._get_iteration_task_config( - al_task, algorithm_config, "active_learn", i - ) + acl_config: TaskConfig = sequential_learner._get_iteration_task_config( + al_task, algorithm_config, "active_learn", i ) acl_task = sequential_learner._register_task( @@ -283,44 +255,29 @@ async def _run_algorithm_pipeline( should_stop: bool stop_value: float - should_stop, stop_value = ( - sequential_learner._check_stop_criterion(stop) - ) + should_stop, stop_value = sequential_learner._check_stop_criterion(stop) final_result = stop_value iteration_count = i + 1 if should_stop: - logger.info( - f"[Algorithm-{algorithm_name}] Stop criterion met " - f"with value of: {final_result}. " - "Breaking the active learning loop." - ) break # Prepare next iteration tasks - next_sim_config: TaskConfig = ( - sequential_learner._get_iteration_task_config( - sequential_learner.simulation_function, - algorithm_config, - "simulation", - i + 1, - ) + next_sim_config: TaskConfig = sequential_learner._get_iteration_task_config( + sequential_learner.simulation_function, + algorithm_config, + "simulation", + i + 1, ) - next_train_config: TaskConfig = ( - sequential_learner._get_iteration_task_config( - sequential_learner.training_function, - algorithm_config, - "training", - i + 1, - ) + next_train_config: TaskConfig = sequential_learner._get_iteration_task_config( + sequential_learner.training_function, + algorithm_config, + "training", + i + 1, ) - sim_task = sequential_learner._register_task( - next_sim_config, deps=acl_task - ) - train_task = sequential_learner._register_task( - next_train_config, deps=sim_task - ) + sim_task = sequential_learner._register_task(next_sim_config, deps=acl_task) + train_task = sequential_learner._register_task(next_train_config, deps=sim_task) # Wait for training to complete await train_task @@ -334,7 +291,7 @@ async def _run_algorithm_pipeline( } self.algorithm_results[algorithm_name] = result_dict - logger.info( + print( f"[Algorithm-{algorithm_name}] Completed " f" with {iteration_count} iterations, final result: {final_result}" ) @@ -342,7 +299,7 @@ async def _run_algorithm_pipeline( return self.algorithm_results[algorithm_name] except Exception as e: - logger.error(f"[Algorithm-{algorithm_name}] Failed with error: {e}") + print(f"[Algorithm-{algorithm_name}] Failed with error: {e}") # Store failure information error_dict: dict[str, Any] = { "iterations": 0, @@ -353,7 +310,7 @@ async def _run_algorithm_pipeline( raise # Submit all algorithm pipelines asynchronously - logger.debug(self.active_learn_functions) + print(self.active_learn_functions) futures: list[Any] = [ _run_algorithm_pipeline(al_name, al_task) for al_name, al_task in self.active_learn_functions.items() @@ -365,10 +322,10 @@ async def _run_algorithm_pipeline( # Process results and handle any exceptions for _, (algorithm_name, result) in enumerate( - zip(self.active_learn_functions.keys(), results) + zip(self.active_learn_functions.keys(), results, strict=False) ): if isinstance(result, Exception): - logger.error(f"[Algorithm-{algorithm_name}] Failed: {result}") + print(f"[Algorithm-{algorithm_name}] Failed: {result}") self.algorithm_results[algorithm_name] = { "iterations": 0, "last_result": float("inf"), @@ -385,7 +342,7 @@ async def _run_algorithm_pipeline( } except Exception as e: - logger.error(f"Error during algorithm selection: {e}") + print(f"Error during algorithm selection: {e}") raise def _select_best_algorithm(self) -> None: @@ -423,13 +380,13 @@ def _select_best_algorithm(self) -> None: self.best_pipeline_name, self.best_pipeline_stats = sorted_algorithms[0] - logger.info( + print( f"Best algorithm is '{self.best_pipeline_name}' " f"with {self.best_pipeline_stats['iterations']} iteration(s) " f"and final metric result {self.best_pipeline_stats['last_result']}" ) - def get_best_algorithm(self) -> tuple[Optional[str], Optional[dict[str, Any]]]: + def get_best_algorithm(self) -> tuple[str | None, dict[str, Any] | None]: """Get the best algorithm name and its statistics. Returns: diff --git a/rose/integrations/__init__.py b/rose/integrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rose/integrations/clearml_tracker.py b/rose/integrations/clearml_tracker.py new file mode 100644 index 00000000..eaf6fa56 --- /dev/null +++ b/rose/integrations/clearml_tracker.py @@ -0,0 +1,157 @@ +"""ClearML tracker integration for ROSE. + +Install the optional dependency before use:: + + pip install rose[clearml] + +Usage:: + + from rose.integrations.clearml_tracker import ClearMLTracker + + learner.add_tracker(ClearMLTracker(project_name="ROSE", task_name="al-run-01")) + async for state in learner.start(max_iter=20): + ... # tracking happens automatically +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from rose.learner import IterationState + from rose.tracking import PipelineManifest + +_TASK_NAMES = ( + "simulation", + "training", + "prediction", + "active_learn", + "environment", + "update", + "criterion", +) + + +class ClearMLTracker: + """TrackerBase implementation backed by ClearML. + + Logs pipeline manifest as hyperparameters on start, scalar metrics per + iteration, and stop reason as a task tag on stop. Task outputs (returned + as dicts) are captured automatically via ``on_iteration``. + + Args: + project_name: ClearML project name (created if it does not exist). + task_name: Display name for the ClearML task. + learner_names: Optional list of human-readable names for parallel + learners. When provided, integer ``learner_id`` values (0, 1, ...) + are mapped to these names as the ClearML scalar series, so the UI + shows ``"ensemble-A"`` instead of ``0``. + """ + + def __init__( + self, + project_name: str, + task_name: str, + learner_names: list[str] | None = None, + ) -> None: + try: + import clearml # noqa: F401 + except ImportError as e: + raise ImportError( + "clearml is required for ClearMLTracker. Install it with: pip install rose[clearml]" + ) from e + + self._project_name = project_name + self._task_name = task_name + self._learner_names: list[str] = learner_names or [] + self._task = None + self._logger = None + + def on_start(self, manifest: PipelineManifest) -> None: + from clearml import Task + + self._task = Task.init( + project_name=self._project_name, + task_name=self._task_name, + ) + self._logger = self._task.get_logger() + + params: dict[str, Any] = { + "learner_type": manifest.learner_type, + "parallel_learners": manifest.parallel_count, + "criterion/metric_name": manifest.criterion.metric_name if manifest.criterion else None, + "criterion/threshold": manifest.criterion.threshold if manifest.criterion else None, + "criterion/operator": manifest.criterion.operator if manifest.criterion else None, + } + for task_key, task_manifest in manifest.tasks.items(): + params[f"task/{task_key}/as_executable"] = task_manifest.as_executable + for k, v in task_manifest.log_params.items(): + params[f"task/{task_key}/{k}"] = v + + self._task.connect(params) + + def on_iteration(self, state: IterationState) -> None: + if self._logger is None: + return + + learner_id = state.learner_id + if learner_id is None: + series: str = "value" + elif ( + self._learner_names + and isinstance(learner_id, int) + and learner_id < len(self._learner_names) + ): + series = self._learner_names[learner_id] + else: + series = str(learner_id) + + if state.metric_value is not None: + metric_name = getattr(state, "metric_name", None) or "metric" + self._logger.report_scalar( + title=metric_name, + series=series, + value=state.metric_value, + iteration=state.iteration, + ) + + for key, value in state.state.items(): + if isinstance(value, (int, float)): + self._logger.report_scalar( + title=key, + series=series, + value=value, + iteration=state.iteration, + ) + + if state.current_config is not None: + string_params: dict[str, str] = {} + for task_name in _TASK_NAMES: + task_cfg = state.current_config.get_task_config(task_name, state.iteration) + if task_cfg is None: + continue + for k, v in task_cfg.kwargs.items(): + if k.startswith("--"): + continue + if isinstance(v, (int, float)): + self._logger.report_scalar( + title=f"config/{task_name}/{k}", + series=series, + value=v, + iteration=state.iteration, + ) + elif isinstance(v, str): + string_params[f"{task_name}/{k}"] = v + if string_params and self._task is not None: + self._task.connect(string_params, name="current_config") + + def on_stop(self, final_state: IterationState | None, reason: str) -> None: + if self._task is None: + return + + self._task.add_tags([f"stop:{reason}"]) + if final_state is not None: + self._task.add_tags([f"final_iter:{final_state.iteration}"]) + if reason == "error": + self._task.mark_failed(status_reason="error during run") + self._task.close() diff --git a/rose/integrations/mlflow_tracker.py b/rose/integrations/mlflow_tracker.py new file mode 100644 index 00000000..7010fbd4 --- /dev/null +++ b/rose/integrations/mlflow_tracker.py @@ -0,0 +1,115 @@ +"""MLflow tracker integration for ROSE. + +Install the optional dependency before use:: + + pip install rose[mlflow] + +Usage:: + + from rose.integrations.mlflow_tracker import MLflowTracker + + learner.add_tracker(MLflowTracker(experiment_name="surrogate-v1")) + async for state in learner.start(max_iter=20): + ... # tracking happens automatically +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from rose.learner import IterationState + from rose.tracking import PipelineManifest + +_TASK_NAMES = ( + "simulation", + "training", + "prediction", + "active_learn", + "environment", + "update", + "criterion", +) + + +class MLflowTracker: + """TrackerBase implementation backed by MLflow. + + Logs pipeline manifest as run parameters on start, metric values per + iteration, and stop reason as a tag on stop. Task outputs (returned as + dicts) are captured automatically via ``on_iteration``. + + Args: + experiment_name: MLflow experiment name (created if it does not exist). + run_name: Optional display name for the run. + """ + + def __init__(self, experiment_name: str, run_name: str | None = None) -> None: + try: + import mlflow # noqa: F401 + except ImportError as e: + raise ImportError( + "mlflow is required for MLflowTracker. Install it with: pip install rose[mlflow]" + ) from e + + self._experiment_name = experiment_name + self._run_name = run_name + self._run = None + + def on_start(self, manifest: PipelineManifest) -> None: + import mlflow + + mlflow.set_experiment(self._experiment_name) + self._run = mlflow.start_run(run_name=self._run_name) + + params: dict[str, Any] = { + "learner_type": manifest.learner_type, + "parallel_learners": manifest.parallel_count, + "criterion/metric_name": manifest.criterion.metric_name if manifest.criterion else None, + "criterion/threshold": manifest.criterion.threshold if manifest.criterion else None, + "criterion/operator": manifest.criterion.operator if manifest.criterion else None, + } + for task_key, task_manifest in manifest.tasks.items(): + params[f"task.{task_key}.as_executable"] = task_manifest.as_executable + for k, v in task_manifest.log_params.items(): + params[f"task.{task_key}.{k}"] = v + + mlflow.log_params(params) + + def on_iteration(self, state: IterationState) -> None: + import mlflow + + step = state.iteration + prefix = f"{state.learner_id}/" if state.learner_id is not None else "" + + if state.metric_value is not None: + metric_name = getattr(state, "metric_name", None) or "metric" + mlflow.log_metric(f"{prefix}{metric_name}", state.metric_value, step=step) + + for key, value in state.state.items(): + if isinstance(value, (int, float)): + mlflow.log_metric(f"{prefix}{key}", value, step=step) + + if state.current_config is not None: + for task_name in _TASK_NAMES: + task_cfg = state.current_config.get_task_config(task_name, step) + if task_cfg is None: + continue + for k, v in task_cfg.kwargs.items(): + if k.startswith("--"): + continue + if isinstance(v, (int, float)): + mlflow.log_metric(f"{prefix}config/{task_name}/{k}", v, step=step) + elif isinstance(v, str): + mlflow.set_tag(f"{prefix}config/{task_name}/{k}", v) + + def on_stop(self, final_state: IterationState | None, reason: str) -> None: + import mlflow + + if self._run is None: + return + + mlflow.set_tag("stop_reason", reason) + if final_state is not None: + mlflow.set_tag("final_iteration", str(final_state.iteration)) + mlflow.end_run(status="FAILED" if reason == "error" else "FINISHED") diff --git a/rose/learner.py b/rose/learner.py index 0b48b416..25d3f8b2 100644 --- a/rose/learner.py +++ b/rose/learner.py @@ -1,8 +1,8 @@ import asyncio -import logging +from collections.abc import AsyncIterator, Callable from dataclasses import dataclass, field from functools import wraps -from typing import Any, Callable, Optional, Union +from typing import Any, Optional import typeguard from pydantic import BaseModel @@ -11,8 +11,7 @@ ) from .metrics import LearningMetrics as Metrics - -logger = logging.getLogger(__name__) +from .tracking import CriterionManifest, PipelineManifest, TaskManifest, TrackerBase @dataclass @@ -47,12 +46,13 @@ class IterationState: """ iteration: int - metric_name: Optional[str] = None - metric_value: Optional[float] = None - metric_threshold: Optional[float] = None + metric_name: str | None = None + metric_value: float | None = None + metric_threshold: float | None = None metric_history: list[float] = field(default_factory=list) should_stop: bool = False current_config: Optional["LearnerConfig"] = None + learner_id: int | str | None = None # All domain-specific state goes here state: dict[str, Any] = field(default_factory=dict) @@ -99,12 +99,60 @@ def to_dict(self) -> dict[str, Any]: "metric_threshold": self.metric_threshold, "metric_history": self.metric_history, "should_stop": self.should_stop, + "learner_id": self.learner_id, } # Merge in all state values result.update(self.state) return result +async def _stream_parallel( + run_fns: list[Callable[[asyncio.Queue], Any]], +) -> AsyncIterator[IterationState]: + """Run multiple learner coroutines in parallel and stream their IterationStates. + + Each callable in ``run_fns`` must accept an ``asyncio.Queue`` and put exactly + three kinds of tuples into it during its lifetime: + + * ``('state', IterationState)`` — for each iteration state to stream + * ``('error', Exception)`` — if the learner raises (before ``'done'``) + * ``('done', None)`` — exactly once, in a ``finally`` block, to signal completion + + This function manages queue creation, task scheduling, result streaming, and + exception propagation so that parallel learner implementations only need to + provide the learner-specific ``run_fn`` logic. + + Args: + run_fns: List of callables, one per parallel learner. Each callable takes + a shared ``asyncio.Queue`` and returns an awaitable coroutine. + + Yields: + IterationState objects in arrival order across all parallel learners. + + Raises: + Exception: The first exception raised by any learner, after all learners + have finished. + """ + queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue() + tasks = [asyncio.create_task(fn(queue)) for fn in run_fns] + + completed = 0 + first_error: Exception | None = None + while completed < len(run_fns): + kind, value = await queue.get() + if kind == "done": + completed += 1 + elif kind == "state": + yield value + elif kind == "error": + if first_error is None: + first_error = value + + await asyncio.gather(*tasks, return_exceptions=True) + if first_error is not None: + raise first_error + + class TaskConfig(BaseModel): """Configuration for a single task. @@ -146,15 +194,15 @@ class LearnerConfig(BaseModel): """ # Active Learning fields - simulation: Optional[Union[TaskConfig, dict[int, TaskConfig]]] = None - training: Optional[Union[TaskConfig, dict[int, TaskConfig]]] = None - prediction: Optional[Union[TaskConfig, dict[int, TaskConfig]]] = None - active_learn: Optional[Union[TaskConfig, dict[int, TaskConfig]]] = None + simulation: TaskConfig | dict[int, TaskConfig] | None = None + training: TaskConfig | dict[int, TaskConfig] | None = None + prediction: TaskConfig | dict[int, TaskConfig] | None = None + active_learn: TaskConfig | dict[int, TaskConfig] | None = None # Reinforcement Learning fields - environment: Optional[Union[TaskConfig, dict[int, TaskConfig]]] = None - update: Optional[Union[TaskConfig, dict[int, TaskConfig]]] = None + environment: TaskConfig | dict[int, TaskConfig] | None = None + update: TaskConfig | dict[int, TaskConfig] | None = None # Common fields - criterion: Optional[Union[TaskConfig, dict[int, TaskConfig]]] = None + criterion: TaskConfig | dict[int, TaskConfig] | None = None class Config: """Pydantic configuration for LearnerConfig.""" @@ -164,7 +212,7 @@ class Config: tuple: list, } - def get_task_config(self, task_name: str, iteration: int) -> Optional[TaskConfig]: + def get_task_config(self, task_name: str, iteration: int) -> TaskConfig | None: """Get the task configuration for a specific iteration. Args: @@ -180,9 +228,7 @@ def get_task_config(self, task_name: str, iteration: int) -> Optional[TaskConfig look for an exact iteration match, then fall back to default configs (key -1 or 'default'). """ - task_config: Optional[Union[TaskConfig, dict[int, TaskConfig]]] = getattr( - self, task_name, None - ) + task_config: TaskConfig | dict[int, TaskConfig] | None = getattr(self, task_name, None) if task_config is None: return None @@ -224,9 +270,7 @@ class Learner: """ @typeguard.typechecked - def __init__( - self, asyncflow: WorkflowEngine, register_and_submit: bool = True - ) -> None: + def __init__(self, asyncflow: WorkflowEngine, register_and_submit: bool = True) -> None: """Initialize the Learner. Args: @@ -259,6 +303,9 @@ def __init__( self._stop_event = asyncio.Event() + self._trackers: list[TrackerBase] = [] + self._iteration_state: IterationState | None = None + @property def is_stopped(self) -> bool: """Check if the learner has been requested to stop.""" @@ -271,12 +318,12 @@ def stop(self) -> None: def _get_iteration_task_config( self, base_task: dict[str, Any], - config: Optional[LearnerConfig], + config: LearnerConfig | None, task_key: str, iteration: int, ) -> dict[str, Any]: - """Get task configuration for a specific iteration, - merging base config with iteration-specific overrides.""" + """Get task configuration for a specific iteration, merging base config with iteration- + specific overrides.""" # Start with a copy of the base task (or empty dict if None) task_config = base_task.copy() if base_task else {} @@ -322,9 +369,7 @@ def create_iteration_schedule( } """ return { - iteration: TaskConfig( - args=config.get("args", ()), kwargs=config.get("kwargs", {}) - ) + iteration: TaskConfig(args=config.get("args", ()), kwargs=config.get("kwargs", {})) for iteration, config in schedule.items() } @@ -368,6 +413,9 @@ def decorator_factory(_func=None, **decor_kwargs) -> Callable: def decorator(func: Callable) -> Callable: # Capture immutable values at decoration time decoration_as_executable = decor_kwargs.pop("as_executable", True) + # log_params is the explicit tracking contract — separate from + # backend resource kwargs (num_gpus, ranks, etc.) + decoration_log_params: dict[str, Any] = decor_kwargs.pop("log_params", {}) decoration_decor_kwargs = decor_kwargs.copy() # Store initial placeholder (so validation passes) @@ -376,6 +424,7 @@ def decorator(func: Callable) -> Callable: "args": (), "kwargs": {}, "decor_kwargs": decoration_decor_kwargs, + "log_params": decoration_log_params, "as_executable": decoration_as_executable, } setattr(self, f"{task_attr_name}_function", base_task_obj) @@ -388,6 +437,7 @@ def wrapper(*args, **kwargs) -> Any: "args": args, "kwargs": kwargs, "decor_kwargs": decoration_decor_kwargs.copy(), + "log_params": decoration_log_params, "as_executable": decoration_as_executable, } @@ -474,7 +524,7 @@ async def async_wrapper(*args, **kwargs) -> tuple[bool, float]: def _register_task( self, task_obj: dict[str, Any], - deps: Optional[Union[Any, tuple[Any, ...]]] = None, + deps: Any | tuple[Any, ...] | None = None, ) -> Any: """Register and submit a task for execution. @@ -562,8 +612,8 @@ def compare_metric( raise ValueError(f"Unknown comparison operator for metric {metric_name}") def _start_pre_loop(self) -> tuple[Any, Any]: - """Start the initial step for active learning by defining and - setting simulation and training tasks. + """Start the initial step for active learning by defining and setting simulation and + training tasks. Returns: tuple containing (simulation_task, training_task) futures. @@ -592,9 +642,7 @@ def _check_stop_criterion(self, stop_task_result: Any) -> tuple[bool, float]: try: metric_value: float = float(stop_task_result) except Exception as e: - raise Exception( - f"Failed to obtain a numerical value from criterion task: {e}" - ) from e + raise Exception(f"Failed to obtain a numerical value from criterion task: {e}") from e # check if the metric value is a number if isinstance(metric_value, (float, int)): @@ -606,17 +654,14 @@ def _check_stop_criterion(self, stop_task_result: Any) -> tuple[bool, float]: self.iteration += 1 if self.compare_metric(metric_name, metric_value, threshold, operator): - logger.info( + print( f"stop criterion metric: {metric_name} " f"is met with value of: {metric_value} " ". Breaking the active learning loop" ) return True, metric_value else: - logger.info( - f"stop criterion metric: {metric_name} " - f"is not met yet ({metric_value})." - ) + print(f"stop criterion metric: {metric_name} is not met yet ({metric_value}).") return False, metric_value else: raise TypeError( @@ -626,12 +671,12 @@ def _check_stop_criterion(self, stop_task_result: Any) -> tuple[bool, float]: def start(self) -> None: """Start method to be implemented by subclasses. + Raises: NotImplementedError: This method must be implemented by subclasses. """ raise NotImplementedError( - "This is not supported, please define your " - "Start method and invoke it directly" + "This is not supported, please define your Start method and invoke it directly" ) def get_metric_results(self) -> list[float]: @@ -713,9 +758,7 @@ def clear_state(self) -> None: """ self._state_registry.clear() - def _extract_state_from_result( - self, result: Any, exclude_keys: Optional[set[str]] = None - ) -> None: + def _extract_state_from_result(self, result: Any, exclude_keys: set[str] | None = None) -> None: """Extract state from task result if it's a dict. This method provides a universal way to extract state from task @@ -779,7 +822,7 @@ def remove_state_callback(self, callback: Callable[[str, Any], None]) -> None: def build_iteration_state( self, iteration: int, - metric_value: Optional[float] = None, + metric_value: float | None = None, should_stop: bool = False, current_config: Optional["LearnerConfig"] = None, ) -> IterationState: @@ -821,6 +864,113 @@ def build_iteration_state( state=state, ) + # ------------------------------------------------------------------ + # Tracker API + # ------------------------------------------------------------------ + + def add_tracker(self, tracker: TrackerBase) -> None: + """Register a tracker and immediately emit the pipeline manifest. + + The manifest is built from the task function dicts already populated + by decorators, so ``add_tracker`` must be called **after** all task + decorators have been applied and **before** ``start()`` is called. + + Args: + tracker: Any object implementing the ``TrackerBase`` protocol. + """ + manifest = self._build_pipeline_manifest() + tracker.on_start(manifest) + self._trackers.append(tracker) + + def _build_pipeline_manifest(self) -> PipelineManifest: + """Build a ``PipelineManifest`` from the currently registered task dicts. + + Reads ``simulation_function``, ``training_function``, + ``active_learn_function``, ``prediction_function``, + ``environment_function`` (RL), and ``criterion_function`` if present. + + Returns: + Populated ``PipelineManifest`` ready to pass to ``tracker.on_start``. + """ + task_attr_names = ( + "simulation_function", + "training_function", + "active_learn_function", + "prediction_function", + "environment_function", + "update_function", + ) + + tasks: dict[str, TaskManifest] = {} + for attr in task_attr_names: + task_dict = getattr(self, attr, None) + if not task_dict or not isinstance(task_dict, dict): + continue + func = task_dict.get("func") + if func is None: + continue + task_key = attr.replace("_function", "") + tasks[task_key] = TaskManifest( + func_name=getattr(func, "__name__", str(func)), + func_module=getattr(func, "__module__", ""), + as_executable=task_dict.get("as_executable", True), + decor_kwargs=task_dict.get("decor_kwargs", {}).copy(), + log_params=task_dict.get("log_params", {}).copy(), + ) + + criterion: CriterionManifest | None = None + crit = self.criterion_function + if crit and isinstance(crit, dict) and crit.get("func") is not None: + func = crit["func"] + criterion = CriterionManifest( + func_name=getattr(func, "__name__", str(func)), + func_module=getattr(func, "__module__", ""), + as_executable=crit.get("as_executable", True), + decor_kwargs=crit.get("decor_kwargs", {}).copy(), + metric_name=crit.get("metric_name", ""), + threshold=crit.get("threshold", 0.0), + operator=crit.get("operator", ""), + ) + + return PipelineManifest( + learner_type=type(self).__name__, + tasks=tasks, + criterion=criterion, + parallel_count=None, # overridden by parallel learners + ) + + def _notify_trackers_iteration(self, state: IterationState) -> None: + """Notify all registered trackers of a completed iteration. + + Called just before each ``yield state`` inside ``start()`` so that + trackers observe the state at the same time as the user's loop body. + + Args: + state: The ``IterationState`` about to be yielded. + """ + for tracker in self._trackers: + try: + tracker.on_iteration(state) + except Exception: + pass + + def _notify_trackers_stop(self, final_state: IterationState | None, reason: str) -> None: + """Notify all registered trackers that the learning loop has exited. + + Called from the ``finally`` block of every ``start()`` implementation. + + Args: + final_state: Last yielded ``IterationState``, or ``None`` if no + iterations ran. + reason: Exit reason — ``"criterion_met"``, ``"max_iter_reached"``, + ``"stopped"``, or ``"error"``. + """ + for tracker in self._trackers: + try: + tracker.on_stop(final_state, reason) + except Exception: + pass + async def shutdown(self, *args, **kwargs) -> Any: """Shutdown the asyncflow workflow engine. diff --git a/rose/rl/experience.py b/rose/rl/experience.py index d8abc3f0..b65ff9b9 100644 --- a/rose/rl/experience.py +++ b/rose/rl/experience.py @@ -6,7 +6,7 @@ from collections import deque from collections.abc import Iterator from dataclasses import dataclass -from typing import Any, Optional, Union +from typing import Any @dataclass @@ -20,8 +20,7 @@ class Experience: class ExperienceBank: - """ - A memory bank for storing and sampling Experience objects. + """A memory bank for storing and sampling Experience objects. The ExperienceBank supports adding individual or batches of experiences, sampling with or without replacement, merging with @@ -46,9 +45,7 @@ class ExperienceBank: load(filepath, max_size): Load a bank from disk. """ - def __init__( - self, max_size: Optional[int] = None, session_id: Optional[str] = None - ): + def __init__(self, max_size: int | None = None, session_id: str | None = None): self.max_size = max_size self._experiences = deque(maxlen=max_size) if max_size else deque() self._rng = random.Random() @@ -105,9 +102,7 @@ def clear(self) -> None: def get_recent(self, n: int) -> list[Experience]: return ( - list(self._experiences)[-n:] - if n <= len(self._experiences) - else list(self._experiences) + list(self._experiences)[-n:] if n <= len(self._experiences) else list(self._experiences) ) def save(self, work_dir: str = ".", bank_file: str = None) -> str: @@ -120,9 +115,9 @@ def save(self, work_dir: str = ".", bank_file: str = None) -> str: return filepath @classmethod - def load(cls, filepath: str, max_size: Optional[int] = None) -> "ExperienceBank": - """ - Load an ExperienceBank from a pickle file. + def load(cls, filepath: str, max_size: int | None = None) -> "ExperienceBank": + """Load an ExperienceBank from a pickle file. + Args: filepath (str): Path to the pickle file containing a list of Experience objects. @@ -154,9 +149,7 @@ def __len__(self) -> int: def __iter__(self) -> Iterator[Experience]: return iter(self._experiences) - def __getitem__( - self, index: Union[int, slice] - ) -> Union[Experience, list[Experience]]: + def __getitem__(self, index: int | slice) -> Experience | list[Experience]: if isinstance(index, slice): return list(self._experiences)[index] return list(self._experiences)[index] diff --git a/rose/rl/reinforcement_learner.py b/rose/rl/reinforcement_learner.py index 50e6b20e..7185ea94 100644 --- a/rose/rl/reinforcement_learner.py +++ b/rose/rl/reinforcement_learner.py @@ -1,17 +1,15 @@ import asyncio +import dataclasses import itertools -import logging import warnings -from collections.abc import AsyncIterator, Coroutine, Iterator +from collections.abc import AsyncIterator, Callable, Iterator from functools import wraps -from typing import Any, Callable, Optional, Union +from typing import Any import typeguard from radical.asyncflow import WorkflowEngine -from rose.learner import IterationState, Learner, LearnerConfig - -logger = logging.getLogger(__name__) +from rose.learner import IterationState, Learner, LearnerConfig, _stream_parallel class ReinforcementLearner(Learner): @@ -33,9 +31,7 @@ class ReinforcementLearner(Learner): """ @typeguard.typechecked - def __init__( - self, asyncflow: WorkflowEngine, register_and_submit: bool = True - ) -> None: + def __init__(self, asyncflow: WorkflowEngine, register_and_submit: bool = True) -> None: """Initialize the ReinforcementLearner. Args: @@ -48,7 +44,7 @@ def __init__( self.update_function = {} self.environment_function = {} - self.learner_id: Optional[int] = None + self.learner_id: int | None = None self.test_function = self.criterion_function # Create custom decorators that immediately register functions @@ -109,16 +105,16 @@ def __init__(self, asyncflow: WorkflowEngine) -> None: managing asynchronous tasks. """ super().__init__(asyncflow, register_and_submit=True) - self._iteration_state: Optional[IterationState] = None - self._pending_config: Optional[LearnerConfig] = None - self._max_iter: Optional[int] = None + self._iteration_state: IterationState | None = None + self._pending_config: LearnerConfig | None = None + self._max_iter: int | None = None async def start( self, max_iter: int = 0, skip_pre_loop: bool = False, skip_environment_step: bool = False, - initial_config: Optional[LearnerConfig] = None, + initial_config: LearnerConfig | None = None, ) -> AsyncIterator[IterationState]: """Start the learner and yield state at each iteration. @@ -165,23 +161,18 @@ async def start( """ # Validation if not skip_environment_step and not self.environment_function: - raise ValueError( - "Environment function must be set unless using external experiences!" - ) + raise ValueError("Environment function must be set unless using external experiences!") if not self.update_function: raise ValueError("Update function must be set!") if not max_iter and not self.criterion_function: - raise ValueError( - "Either max_iter > 0 or criterion_function must be provided." - ) + raise ValueError("Either max_iter > 0 or criterion_function must be provided.") self._max_iter = max_iter if max_iter > 0 else None learner_config = initial_config + _stop_reason = "max_iter_reached" - learner_suffix = ( - f" (Learner-{self.learner_id})" if self.learner_id is not None else "" - ) - logger.info(f"Starting Sequential RL Learner{learner_suffix}") + learner_suffix = f" (Learner-{self.learner_id})" if self.learner_id is not None else "" + print(f"Starting Sequential RL Learner{learner_suffix}") # Initialize task references env_task: Any = () @@ -215,98 +206,108 @@ async def start( update_result = await update_task # Determine iteration range - iteration_range: Union[Iterator[int], range] + iteration_range: Iterator[int] | range if max_iter == 0: iteration_range = itertools.count() else: iteration_range = range(max_iter) # Main iteration loop - for i in iteration_range: - learner_prefix = ( - f"[Learner-{self.learner_id}] " if self.learner_id is not None else "" - ) - if self.is_stopped: - logger.info(f"{learner_prefix}Stop requested, exiting learning loop.") - break - - # Check for pending config update - if self._pending_config is not None: - learner_config = self._pending_config - self._pending_config = None + try: + for i in iteration_range: + learner_prefix = ( + f"[Learner-{self.learner_id}] " if self.learner_id is not None else "" + ) + if self.is_stopped: + print(f"{learner_prefix}Stop requested, exiting learning loop.") + _stop_reason = "stopped" + break - # Clear transient state from previous iteration - self.clear_state() + # Check for pending config update + if self._pending_config is not None: + learner_config = self._pending_config + self._pending_config = None - # Extract state from env/update results - # (prepared in previous iteration or pre-loop) - if not skip_environment_step and env_result is not None: - self._extract_state_from_result(env_result) - if update_result is not None: - self._extract_state_from_result(update_result) + # Clear transient state from previous iteration + self.clear_state() - learner_prefix = ( - f"[Learner-{self.learner_id}] " if self.learner_id is not None else "" - ) - logger.info(f"{learner_prefix}Starting Iteration-{i}") + # Extract state from env/update results + # (prepared in previous iteration or pre-loop) + if not skip_environment_step and env_result is not None: + self._extract_state_from_result(env_result) + if update_result is not None: + self._extract_state_from_result(update_result) - # Check stop criterion if configured - metric_value: Optional[float] = None - should_stop = False + print(f"{learner_prefix}Starting Iteration-{i}") - if self.criterion_function: - criterion_config = self._get_iteration_task_config( - self.criterion_function, learner_config, "criterion", i - ) - stop_task = self._register_task(criterion_config, deps=update_task) - stop_result = await stop_task + # Check stop criterion if configured + metric_value: float | None = None + should_stop = False - # Extract state from criterion result, excluding handled keys - self._extract_state_from_result( - stop_result, exclude_keys={"metric_value", "should_stop"} - ) + if self.criterion_function: + criterion_config = self._get_iteration_task_config( + self.criterion_function, learner_config, "criterion", i + ) + stop_task = self._register_task(criterion_config, deps=update_task) + stop_result = await stop_task - should_stop, metric_value = self._check_stop_criterion(stop_result) + # Extract state from criterion result, excluding handled keys + self._extract_state_from_result( + stop_result, exclude_keys={"metric_value", "should_stop"} + ) - # Build iteration state - self._iteration_state = self.build_iteration_state( - iteration=i, - metric_value=metric_value, - should_stop=should_stop, - current_config=learner_config, - ) + should_stop, metric_value = self._check_stop_criterion(stop_result) - # YIELD CONTROL TO CALLER - yield self._iteration_state + # Build iteration state + self._iteration_state = self.build_iteration_state( + iteration=i, + metric_value=metric_value, + should_stop=should_stop, + current_config=learner_config, + ) - # Check if caller broke out or criterion met - if should_stop: - break + # Notify trackers then yield control to caller + self._notify_trackers_iteration(self._iteration_state) + yield self._iteration_state - # Prepare next iteration using potentially updated config - next_config = self._pending_config or learner_config - next_update_config = self._get_iteration_task_config( - self.update_function, next_config, "update", i + 1 - ) + # Check if caller broke out or criterion met + if should_stop: + _stop_reason = "criterion_met" + break - if skip_environment_step: - env_task = () - env_result = None - update_task = self._register_task(next_update_config, deps=stop_task) - else: - next_env_config = self._get_iteration_task_config( - self.environment_function, next_config, "environment", i + 1 + # Prepare next iteration using potentially updated config + next_config = self._pending_config or learner_config + next_update_config = self._get_iteration_task_config( + self.update_function, next_config, "update", i + 1 ) - env_task = self._register_task(next_env_config, deps=stop_task) - update_task = self._register_task(next_update_config, deps=env_task) - # Await environment result (extract state in next iteration) - env_result = await env_task + if skip_environment_step: + env_task = () + env_result = None + update_task = self._register_task(next_update_config, deps=stop_task) + else: + next_env_config = self._get_iteration_task_config( + self.environment_function, next_config, "environment", i + 1 + ) + env_task = self._register_task(next_env_config, deps=stop_task) + update_task = self._register_task(next_update_config, deps=env_task) + + # Await environment result (extract state in next iteration) + env_result = await env_task + if self.is_stopped: + _stop_reason = "stopped" + break + + # Await update result (extract state in next iteration) + update_result = await update_task if self.is_stopped: + _stop_reason = "stopped" break - - # Await update result (extract state in next iteration) - update_result = await update_task + except Exception: + _stop_reason = "error" + raise + finally: + self._notify_trackers_stop(self._iteration_state, _stop_reason) def set_next_config(self, config: LearnerConfig) -> None: """Set configuration for the next iteration. @@ -327,7 +328,7 @@ def set_next_config(self, config: LearnerConfig) -> None: """ self._pending_config = config - def get_current_state(self) -> Optional[IterationState]: + def get_current_state(self) -> IterationState | None: """Get the current iteration state. Returns: @@ -335,7 +336,7 @@ def get_current_state(self) -> Optional[IterationState]: """ return self._iteration_state - def get_max_iterations(self) -> Optional[int]: + def get_max_iterations(self) -> int | None: """Get the maximum iterations configured for current run. Returns: @@ -348,8 +349,8 @@ async def learn( max_iter: int = 0, skip_pre_loop: bool = False, skip_simulation_step: bool = False, - learner_config: Optional[LearnerConfig] = None, - ) -> Optional[IterationState]: + learner_config: LearnerConfig | None = None, + ) -> IterationState | None: """Run reinforcement learning loop to completion. .. deprecated:: @@ -446,9 +447,9 @@ def __init__(self, asyncflow: WorkflowEngine) -> None: super().__init__(asyncflow, register_and_submit=False) self.environment_functions: dict[str, dict] = {} self.work_dir = "." - self._iteration_state: Optional[IterationState] = None - self._pending_config: Optional[LearnerConfig] = None - self._max_iter: Optional[int] = None + self._iteration_state: IterationState | None = None + self._pending_config: LearnerConfig | None = None + self._max_iter: int | None = None def environment_task(self, name: str) -> Callable: """Decorator to register an environment task under a given name. @@ -515,10 +516,10 @@ def merge_banks(self) -> None: bank_files.append(os.path.join(self.work_dir, filename)) if not bank_files: - logger.warning("No experience banks found!") + print("No experience banks found!") return - logger.info(f"Found {len(bank_files)} experience banks") + print(f"Found {len(bank_files)} experience banks") # Create merged bank and load all files merged = ExperienceBank() @@ -529,26 +530,26 @@ def merge_banks(self) -> None: bank = ExperienceBank.load(bank_file) merged.merge_inplace(bank) total += len(bank) - logger.info(f" Merged {len(bank)} from {os.path.basename(bank_file)}") + print(f" Merged {len(bank)} from {os.path.basename(bank_file)}") except Exception as e: - logger.error(f" Failed to load {bank_file}: {e}") + print(f" Failed to load {bank_file}: {e}") # Clean up individual bank files for bank_file in bank_files: try: os.remove(bank_file) except Exception as e: - logger.error(f" Failed to delete {bank_file}: {e}") + print(f" Failed to delete {bank_file}: {e}") # Save merged bank merged.save(self.work_dir, "experience_bank.pkl") - logger.info(f" Saved merged bank with {total} total experiences") + print(f" Saved merged bank with {total} total experiences") async def start( self, max_iter: int = 0, skip_pre_loop: bool = False, - initial_config: Optional[LearnerConfig] = None, + initial_config: LearnerConfig | None = None, ) -> AsyncIterator[IterationState]: """Start the learner and yield state at each iteration. @@ -577,17 +578,13 @@ async def start( raise ValueError("Environment and Update functions must be set!") if not max_iter and not self.criterion_function: - raise ValueError( - "Either max_iter > 0 or criterion_function must be provided." - ) + raise ValueError("Either max_iter > 0 or criterion_function must be provided.") self._max_iter = max_iter if max_iter > 0 else None learner_config = initial_config - learner_suffix = ( - f" (Learner-{self.learner_id})" if self.learner_id is not None else "" - ) - logger.info(f"Starting Parallel Experience RL Learner{learner_suffix}") + learner_suffix = f" (Learner-{self.learner_id})" if self.learner_id is not None else "" + print(f"Starting Parallel Experience RL Learner{learner_suffix}") update_task: Any = () @@ -620,7 +617,7 @@ async def start( update_result = await update_task # Determine iteration range - iteration_range: Union[Iterator[int], range] + iteration_range: Iterator[int] | range if max_iter == 0: iteration_range = itertools.count() else: @@ -644,13 +641,11 @@ async def start( if update_result is not None: self._extract_state_from_result(update_result) - learner_prefix = ( - f"[Learner-{self.learner_id}] " if self.learner_id is not None else "" - ) - logger.info(f"{learner_prefix}Starting Iteration-{i}") + learner_prefix = f"[Learner-{self.learner_id}] " if self.learner_id is not None else "" + print(f"{learner_prefix}Starting Iteration-{i}") # Check stop criterion if configured - metric_value: Optional[float] = None + metric_value: float | None = None should_stop = False stop_task = None @@ -710,7 +705,7 @@ async def start( # Await update result (extract state in next iteration) update_result = await update_task - logger.info(f"{learner_prefix}Finished Iteration-{i}") + print(f"{learner_prefix}Finished Iteration-{i}") def set_next_config(self, config: LearnerConfig) -> None: """Set configuration for the next iteration. @@ -720,11 +715,11 @@ def set_next_config(self, config: LearnerConfig) -> None: """ self._pending_config = config - def get_current_state(self) -> Optional[IterationState]: + def get_current_state(self) -> IterationState | None: """Get the current iteration state.""" return self._iteration_state - def get_max_iterations(self) -> Optional[int]: + def get_max_iterations(self) -> int | None: """Get the maximum iterations configured for current run.""" return self._max_iter @@ -732,8 +727,8 @@ async def learn( self, max_iter: int = 0, skip_pre_loop: bool = False, - learner_config: Optional[LearnerConfig] = None, - ) -> Optional[IterationState]: + learner_config: LearnerConfig | None = None, + ) -> IterationState | None: """Run parallel experience RL loop to completion. .. deprecated:: @@ -767,17 +762,15 @@ async def learn( class ParallelReinforcementLearner(ReinforcementLearner): - """Parallel reinforcement learner that runs multiple - SequentialReinforcementLearners concurrently. + """Parallel reinforcement learner that runs multiple SequentialReinforcementLearners + concurrently. - This class orchestrates multiple SequentialReinforcementLearner - instances to run in parallel, allowing for concurrent exploration - of the learning space. Each learner can be configured independently - through per-learner LearnerConfig objects. + This class orchestrates multiple SequentialReinforcementLearner instances to run in parallel, + allowing for concurrent exploration of the learning space. Each learner can be configured + independently through per-learner LearnerConfig objects. - The parallel learner manages the lifecycle of all sequential - learners and collects their results when all have completed their - learning processes. + The parallel learner manages the lifecycle of all sequential learners and collects their results + when all have completed their learning processes. """ def __init__(self, asyncflow: WorkflowEngine) -> None: @@ -790,7 +783,7 @@ def __init__(self, asyncflow: WorkflowEngine) -> None: super().__init__(asyncflow, register_and_submit=False) def _create_sequential_learner( - self, learner_id: int, config: Optional[LearnerConfig] + self, learner_id: int, config: LearnerConfig | None ) -> SequentialReinforcementLearner: """Create a SequentialReinforcementLearner instance for a parallel learner. @@ -809,8 +802,8 @@ def _create_sequential_learner( independently in the parallel learning environment. """ # Create a new sequential learner with the same asyncflow - sequential_learner: SequentialReinforcementLearner = ( - SequentialReinforcementLearner(self.asyncflow) + sequential_learner: SequentialReinforcementLearner = SequentialReinforcementLearner( + self.asyncflow ) # Copy the base functions from the parent learner @@ -824,8 +817,8 @@ def _create_sequential_learner( return sequential_learner def _convert_to_sequential_config( - self, parallel_config: Optional[LearnerConfig] - ) -> Optional[LearnerConfig]: + self, parallel_config: LearnerConfig | None + ) -> LearnerConfig | None: """Convert a LearnerConfig to a LearnerConfig. Note: This method currently performs a direct copy as both parallel and @@ -858,15 +851,18 @@ async def start( parallel_learners: int = 2, max_iter: int = 0, skip_pre_loop: bool = False, - learner_configs: Optional[list[Optional[LearnerConfig]]] = None, - ) -> list[Any]: + learner_configs: list[LearnerConfig | None] | None = None, + ) -> AsyncIterator[IterationState]: """Run parallel reinforcement learning by launching multiple - SequentialReinforcementLearners. + SequentialReinforcementLearners. Orchestrates multiple SequentialReinforcementLearner instances to - run concurrently, each with potentially different configurations. All - learners run independently and their results are collected when all - have completed. + run concurrently, each with potentially different configurations. States + are streamed in real time as each learner completes an iteration — use + ``async for`` to consume them. + + Each yielded ``IterationState`` includes a ``learner_id`` field (int) + indicating which parallel learner produced it. Args: parallel_learners: Number of parallel learners to run concurrently. @@ -880,15 +876,21 @@ async def start( If None, all learners use default configuration. Length must match parallel_learners if provided. - Returns: - list containing the final IterationState from each learner, - in the same order as the learners were launched. + Yields: + IterationState for each iteration of each learner, in arrival order. + Each state has ``learner_id`` set to the integer index of the learner. Raises: ValueError: If parallel_learners < 2. ValueError: If required base functions are not set. ValueError: If neither max_iter nor criterion_function is provided. ValueError: If learner_configs length doesn't match parallel_learners. + Exception: Re-raises any exception from a learner after all learners finish. + + Example:: + + async for state in rl.start(parallel_learners=3, max_iter=100): + print(f"Learner {state.learner_id}, iter {state.iteration}: {state.metric_value}") """ if parallel_learners < 2: raise ValueError("For single learner, use SequentialReinforcementLearner") @@ -898,85 +900,60 @@ async def start( raise ValueError("Environment and Update functions must be set!") if not max_iter and not self.criterion_function: - raise ValueError( - "Either max_iter > 0 or criterion_function must be provided." - ) + raise ValueError("Either max_iter > 0 or criterion_function must be provided.") # Prepare learner configurations learner_configs = learner_configs or [None] * parallel_learners if len(learner_configs) != parallel_learners: raise ValueError("learner_configs length must match parallel_learners") - logger.info( - f"Starting Parallel Reinforcement Learning " - f"with {parallel_learners} learners" - ) - - async def rl_learner_workflow(learner_id: int) -> Any: - """Run a single SequentialReinforcementLearner. - - Internal async function that manages the lifecycle of a single - SequentialReinforcementLearner within the parallel learning context. + print(f"Starting Parallel Reinforcement Learning with {parallel_learners} learners") - Args: - learner_id: Unique identifier for this learner instance. - - Returns: - The final IterationState from the sequential learner. - - Raises: - Exception: Re-raises any exception from the sequential learner - with additional context about which learner failed. - """ - try: - # Create and configure the sequential learner - sequential_learner: SequentialReinforcementLearner = ( - self._create_sequential_learner( - learner_id, learner_configs[learner_id] + # Factory required: plain closure in a loop would capture the same variable reference. + def make_run_fn(learner_id: int): + async def run_learner(queue: asyncio.Queue) -> None: + try: + sequential_learner: SequentialReinforcementLearner = ( + self._create_sequential_learner(learner_id, learner_configs[learner_id]) ) - ) - - # Convert parallel config to sequential config - sequential_config: Optional[LearnerConfig] = ( - self._convert_to_sequential_config(learner_configs[learner_id]) - ) - - # Run the sequential learner by iterating through start() - final_state = None - async for state in sequential_learner.start( - max_iter=max_iter, - skip_pre_loop=skip_pre_loop, - initial_config=sequential_config, - ): - final_state = state - # Let the learner run to completion - if self.is_stopped: - sequential_learner.stop() - - # Store metrics per learner - self.metric_values_per_iteration[f"learner-{learner_id}"] = ( - sequential_learner.metric_values_per_iteration - ) - - return final_state - except Exception as e: - logger.error(f"RLLearner-{learner_id}] failed with error: {e}") - raise - - # Submit all learners asynchronously - futures: list[Coroutine] = [ - rl_learner_workflow(i) for i in range(parallel_learners) - ] - - # Wait for all learners to complete and collect results - return await asyncio.gather(*futures) + async for state in sequential_learner.start( + max_iter=max_iter, + skip_pre_loop=skip_pre_loop, + initial_config=learner_configs[learner_id], + ): + if self.is_stopped: + sequential_learner.stop() + await queue.put( + ("state", dataclasses.replace(state, learner_id=learner_id)) + ) + self.metric_values_per_iteration[f"learner-{learner_id}"] = ( + sequential_learner.metric_values_per_iteration + ) + except Exception as e: + print(f"[RLLearner-{learner_id}] failed with error: {e}") + await queue.put(("error", e)) + finally: + await queue.put(("done", None)) + + return run_learner + + _stop_reason = "max_iter_reached" + try: + async for state in _stream_parallel([make_run_fn(i) for i in range(parallel_learners)]): + self._notify_trackers_iteration(state) + yield state + except Exception: + _stop_reason = "error" + raise + finally: + self._notify_trackers_stop(self._iteration_state, _stop_reason) async def learn( self, parallel_learners: int = 2, max_iter: int = 0, skip_pre_loop: bool = False, - learner_configs: Optional[list[Optional[LearnerConfig]]] = None, + learner_configs: list[LearnerConfig | None] | None = None, ) -> list[Any]: """Run parallel reinforcement learning to completion. @@ -994,14 +971,16 @@ async def learn( list containing the final IterationState from each learner. """ warnings.warn( - "learn() is deprecated and will be removed in a future version. " - "Use start() instead.", + "learn() is deprecated and will be removed in a future version. Use start() instead.", DeprecationWarning, stacklevel=2, ) - return await self.start( + final_states: dict[int, IterationState | None] = {} + async for state in self.start( parallel_learners=parallel_learners, max_iter=max_iter, skip_pre_loop=skip_pre_loop, learner_configs=learner_configs, - ) + ): + final_states[state.learner_id] = state + return [final_states.get(i) for i in range(parallel_learners)] diff --git a/rose/tracking.py b/rose/tracking.py new file mode 100644 index 00000000..8ff10d53 --- /dev/null +++ b/rose/tracking.py @@ -0,0 +1,175 @@ +"""Tracking protocol and pipeline manifest types for ROSE. + +This module defines the ``TrackerBase`` protocol — a pluggable observability interface that +learners call at three lifecycle points: + +* ``on_start`` — once, immediately after ``add_tracker()`` is called (manifest is complete) +* ``on_iteration`` — once per iteration, with the full ``IterationState`` snapshot +* ``on_stop`` — once, when the learning loop exits for any reason + +Task outputs flow into the tracker via ``on_iteration``: when a task returns a ``dict``, +ROSE automatically extracts each key-value pair into ``IterationState.state``, making +them available in the ``state`` argument passed to every ``on_iteration`` call. + +Concrete implementations live in ``rose/integrations/`` (MLflow, ClearML) and are +optional — ROSE core never imports them. + +Example:: + + from rose.tracking import TrackerBase, PipelineManifest + from rose.learner import IterationState + + class PrintTracker: + def on_start(self, manifest: PipelineManifest) -> None: + print(f"Starting {manifest.learner_type}") + + def on_iteration(self, state: IterationState) -> None: + print(f" iter {state.iteration}: metric={state.metric_value}") + + def on_stop(self, final_state, reason: str) -> None: + print(f"Stopped: {reason}") + + learner.add_tracker(PrintTracker()) +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from rose.learner import IterationState + + +# --------------------------------------------------------------------------- +# Pipeline manifest — captures the registered pipeline at decoration time +# --------------------------------------------------------------------------- + + +@dataclass +class TaskManifest: + """Snapshot of a single registered task captured at decoration time. + + Attributes: + func_name: The decorated function's ``__name__``. + func_module: The decorated function's ``__module__``. + as_executable: ``True`` when submitted via ``asyncflow.executable_task``, + ``False`` when submitted via ``asyncflow.function_task``. + decor_kwargs: Extra keyword arguments forwarded to the execution backend + (e.g. ``num_gpus``, ``ranks``). These are opaque to trackers — + they are backend resource specifications, not experiment metadata. + log_params: Explicit tracking parameters declared via the ``log_params`` + decorator keyword. Only these values are forwarded to trackers. + Example: ``@learner.training_task(num_gpus=4, log_params={"num_gpus": 4})``. + """ + + func_name: str + func_module: str + as_executable: bool + decor_kwargs: dict[str, Any] = field(default_factory=dict) + log_params: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class CriterionManifest(TaskManifest): + """Snapshot of the stop-criterion task, extending ``TaskManifest`` with metric metadata. + + Attributes: + metric_name: Name of the metric being tracked (e.g. ``"MSE"``). + threshold: Target threshold value for the stopping condition. + operator: Comparison operator string (``"<"``, ``">"``, etc.). + """ + + metric_name: str = "" + threshold: float = 0.0 + operator: str = "" + + +@dataclass +class PipelineManifest: + """Complete snapshot of the registered pipeline, built at ``add_tracker()`` time. + + All information comes from the decorator metadata already stored on the learner — + no user annotation required. + + Attributes: + learner_type: The learner class name (e.g. ``"SequentialActiveLearner"``). + tasks: Dictionary of registered tasks keyed by task name + (``"simulation"``, ``"training"``, ``"active_learn"``, etc.). + criterion: Stop-criterion manifest, or ``None`` if no criterion is registered. + parallel_count: Number of parallel learners for parallel runs, ``None`` for + sequential runs. + """ + + learner_type: str + tasks: dict[str, TaskManifest] = field(default_factory=dict) + criterion: CriterionManifest | None = None + parallel_count: int | None = None + + +# --------------------------------------------------------------------------- +# TrackerBase protocol +# --------------------------------------------------------------------------- + + +class TrackerBase: + """Protocol defining the tracking interface for ROSE learners. + + Implement any subset of these methods to observe a learner's run. All + methods have default no-op implementations so you only override what you + need. + + The three lifecycle methods map to the outer learning loop: + + - ``on_start`` → pipeline manifest, fired once at ``add_tracker()`` + - ``on_iteration`` → ``IterationState`` snapshot after each iteration completes + - ``on_stop`` → final state and stop reason when the loop exits + + Task outputs are captured via return values: when a task returns a ``dict``, + ROSE extracts each key-value pair into ``IterationState.state`` automatically, + making them available in every ``on_iteration`` call. + """ + + def on_start(self, manifest: PipelineManifest) -> None: + """Called once immediately when ``add_tracker()`` is invoked. + + The pipeline manifest is already fully populated at this point (all + task decorators have fired), so ``manifest`` contains the complete + pipeline definition. + + Args: + manifest: Full pipeline snapshot (task names, functions, criterion + metadata, parallel count). + """ + + def on_iteration(self, state: IterationState) -> None: + """Called once per iteration with the complete ``IterationState`` snapshot. + + Invoked just before ``yield state`` inside ``start()``, so it fires + before the user's ``async for`` loop body runs. The state contains + the consolidated snapshot of all task outputs registered via + ``register_state()`` during this iteration. + + Args: + state: Complete iteration snapshot including ``iteration``, + ``metric_value``, ``should_stop``, ``current_config``, + ``learner_id``, and ``state`` dict with all registered task data. + """ + + def on_stop(self, final_state: IterationState | None, reason: str) -> None: + """Called once when the learning loop exits for any reason. + + Invoked from the ``finally`` block of ``start()``, so it fires whether + the loop completed normally, hit a stop criterion, was externally + stopped, or raised an exception. + + Args: + final_state: The last ``IterationState`` that was yielded, or + ``None`` if no iterations ran (e.g. validation error on start). + reason: One of: + - ``"criterion_met"`` — stop criterion threshold was reached + - ``"max_iter_reached"`` — all iterations completed normally + - ``"stopped"`` — ``learner.stop()`` was called or user broke + out of the ``async for`` loop + - ``"error"`` — an unhandled exception occurred + """ diff --git a/rose/uq/uq_active_learner.py b/rose/uq/uq_active_learner.py index 7bdbcbca..fb9f5e93 100644 --- a/rose/uq/uq_active_learner.py +++ b/rose/uq/uq_active_learner.py @@ -1,27 +1,23 @@ import asyncio import copy +import dataclasses import itertools -import logging import warnings from collections.abc import AsyncIterator, Iterator -from typing import Any, Optional, Union +from typing import Any from radical.asyncflow import WorkflowEngine from rose.uq.uq_learner import UQLearner, UQLearnerConfig -from ..learner import IterationState, TaskConfig - -logger = logging.getLogger(__name__) +from ..learner import IterationState, TaskConfig, _stream_parallel class SeqUQLearner(UQLearner): - """UQ active learner that runs iterations one after another. - This class implements a sequential active learning approach based - on Uncertainty Quantification. - Each iteration consists of simulation, a set of training and prediction steps, - and active learning phases executed in sequence. - The learner can be configured with per-iteration parameters using UQLearnerConfig. + """UQ active learner that runs iterations one after another. This class implements a sequential + active learning approach based on Uncertainty Quantification. Each iteration consists of + simulation, a set of training and prediction steps, and active learning phases executed in + sequence. The learner can be configured with per-iteration parameters using UQLearnerConfig. Attributes: learner_name (Optional[str]) : Identifier for the learner. @@ -36,7 +32,6 @@ def __init__(self, asyncflow: WorkflowEngine) -> None: Args: asyncflow: The workflow engine instance for managing async tasks. - """ super().__init__(asyncflow) @@ -46,7 +41,7 @@ async def start( num_predictions: int = 1, max_iter: int = 0, skip_pre_loop: bool = False, - learning_config: Optional[dict[str, UQLearnerConfig]] = None, + learning_config: dict[str, UQLearnerConfig] | None = None, ) -> AsyncIterator[IterationState]: """Start the UQ learner and yield state at each iteration. @@ -92,35 +87,28 @@ async def start( or not self.active_learn_function ): raise ValueError( - "Simulation, Training, prediction, and at least" - " one AL function must be set!" + "Simulation, Training, prediction, and at least one AL function must be set!" ) # Validate exit criteria if not max_iter and not self.criterion_function: - raise ValueError( - "Either max_iter or stop_criterion_function must be provided." - ) + raise ValueError("Either max_iter or stop_criterion_function must be provided.") # Initialize learner configs if not provided learning_config = learning_config or {} - logger.info(f"[Learner {self.learner_name}] Starting execution...") + print(f"[Learner {self.learner_name}] Starting execution...") if len(model_names) > 1: - prefix = ( - f"[Learner {self.learner_name}] starting training for " - "Ensemble of Models: " - ) + prefix = f"[Learner {self.learner_name}] starting training for Ensemble of Models: " else: - prefix = ( - f"[Learner {self.learner_name}] starting training for Single Model: " - ) - logger.info(f"{prefix} {model_names}") + prefix = f"[Learner {self.learner_name}] starting training for Single Model: " + print(f"{prefix} {model_names}") async def _training_stage( learning_config: TaskConfig, model_name: str, iteration_count: int ) -> dict[str, Any]: - """Run a simulation, train of a single model, and generate a set of - predictions for that model. + """Run a simulation, train of a single model, and generate a set of predictions for that + model. + Args: learning_config: Configuration object for retrieving task configurations. @@ -152,9 +140,7 @@ async def _training_stage( training_config["kwargs"]["--model_name"] = model_name sim_task = self._register_task(sim_config) - training_task = await self._register_task( - training_config, deps=sim_task - ) + training_task = await self._register_task(training_config, deps=sim_task) prediction_tasks = [] for i in range(num_predictions): @@ -166,190 +152,194 @@ async def _training_stage( ) prediction_config["kwargs"]["--model_name"] = model_name prediction_config["kwargs"]["--iteration"] = i - prediction_task = self._register_task( - prediction_config, deps=training_task - ) + prediction_task = self._register_task(prediction_config, deps=training_task) prediction_tasks.append(prediction_task) - logger.info( + print( f"[{self.learner_name}-{model_name}] Completed training " f"for {iteration_count + 1} iteration(s) " ) return await asyncio.gather(*prediction_tasks) except Exception as e: - logger.error( - f"[{self.learner_name}-{model_name}] " - f"Failed train/prediction with error: {e}" - ) + print(f"[{self.learner_name}-{model_name}] Failed train/prediction with error: {e}") raise # Track iterations and results for this pipeline iteration_count: int = 0 stop_training = {name: False for name in model_names} + _stop_reason = "max_iter_reached" # Initialize tasks for pre-loop training_tasks: tuple = () if not skip_pre_loop: futures: list[Any] = [ - _training_stage(learning_config, model_name, 0) - for model_name in model_names + _training_stage(learning_config, model_name, 0) for model_name in model_names ] training_tasks = await asyncio.gather(*futures) # Determine iteration range - iteration_range: Union[Iterator[int], range] + iteration_range: Iterator[int] | range if not max_iter: iteration_range = itertools.count() else: iteration_range = range(max_iter) # Main learning loop - for i in iteration_range: - if self.is_stopped: - logger.info( - f"[Learner {self.learner_name}] Stop requested, " - "exiting learning loop." - ) - break - - # Clear transient state from previous iteration - self.clear_state() - - logger.info(f"[Learner {self.learner_name}] Starting Iteration-{i}") - - # Check uncertainty if configured - uq_task: tuple = () - uq_stop_value: Optional[float] = None - if self.uncertainty_function: - # Get iteration-specific configurations - uq_config: TaskConfig = self._get_iteration_task_config( - self.uncertainty_function, learning_config, "uncertainty", i - ) - uq_task = self._register_task(uq_config, deps=training_tasks) - uq_value = await uq_task + try: + for i in iteration_range: if self.is_stopped: + print(f"[Learner {self.learner_name}] Stop requested, exiting learning loop.") + _stop_reason = "stopped" break - logger.info(f"[Learner {self.learner_name}] {uq_value}") - uq_model_stop, uq_stop_value = self._check_uncertainty(uq_value) - self.register_state("uq_value", uq_stop_value) + # Clear transient state from previous iteration + self.clear_state() - if uq_model_stop: - logger.info( - f"[Learner {self.learner_name}] UQ value reached " - f"its threshold - Stopping training for all models" - f" at iteration {i} with value: " - f"{uq_stop_value}" - ) - # Build final iteration state before breaking - iteration_state = self.build_iteration_state( - iteration=i, - metric_value=None, - should_stop=True, - current_config=learning_config, + print(f"[Learner {self.learner_name}] Starting Iteration-{i}") + + # Check uncertainty if configured + uq_task: tuple = () + uq_stop_value: float | None = None + if self.uncertainty_function: + # Get iteration-specific configurations + uq_config: TaskConfig = self._get_iteration_task_config( + self.uncertainty_function, learning_config, "uncertainty", i ) - yield iteration_state - break + uq_task = self._register_task(uq_config, deps=training_tasks) + uq_value = await uq_task + if self.is_stopped: + _stop_reason = "stopped" + break + print(f"[Learner {self.learner_name}] {uq_value}") - # Get iteration-specific configurations - acl_config: TaskConfig = self._get_iteration_task_config( - self.active_learn_function, learning_config, "active_learn", i - ) - acl_task = self._register_task( - acl_config, - deps=(uq_task if uq_task else training_tasks), - ) - al_results = await acl_task - if self.is_stopped: - break - self._extract_state_from_result(al_results) - - logger.info(f"[Learner {self.learner_name}] {al_results}") - - # Check stop criterion if configured - metric_value: Optional[float] = None - should_stop = False - - if self.criterion_function: - stop_tasks = {} - # Run validation for each model in learner - for model_name in model_names: - if stop_training[model_name]: - continue - criterion_function = copy.deepcopy(self.criterion_function) - criterion_function["kwargs"]["--model_name"] = model_name - stop_task = self._register_task(criterion_function, deps=acl_task) - stop_tasks[model_name] = stop_task - - results = await asyncio.gather(*stop_tasks.values()) + uq_model_stop, uq_stop_value = self._check_uncertainty(uq_value) + self.register_state("uq_value", uq_stop_value) + + if uq_model_stop: + print( + f"[Learner {self.learner_name}] UQ value reached " + f"its threshold - Stopping training for all models" + f" at iteration {i} with value: " + f"{uq_stop_value}" + ) + # Build final iteration state before breaking + self._iteration_state = self.build_iteration_state( + iteration=i, + metric_value=None, + should_stop=True, + current_config=learning_config, + ) + _stop_reason = "criterion_met" + self._notify_trackers_iteration(self._iteration_state) + yield self._iteration_state + break + + # Get iteration-specific configurations + acl_config: TaskConfig = self._get_iteration_task_config( + self.active_learn_function, learning_config, "active_learn", i + ) + acl_task = self._register_task( + acl_config, + deps=(uq_task if uq_task else training_tasks), + ) + al_results = await acl_task if self.is_stopped: + _stop_reason = "stopped" break - stops = dict(zip(stop_tasks.keys(), results)) - - model_stop: bool - stop_value: float - # The pipeline will stop once all models meet the exit criteria. - should_stop_count: int = 0 - final_results = [] - for model_name, stop in stops.items(): - model_stop, stop_value = self._check_stop_criterion(stop) - final_results.append(stop_value) - if model_stop: - stop_training[model_name] = True - should_stop_count += 1 - logger.info( - f"[Learner {self.learner_name}] Model " - f"{model_name} will stop training" - f" as stop criterion is met at iteration {i}" + self._extract_state_from_result(al_results) + + print(f"[Learner {self.learner_name}] {al_results}") + + # Check stop criterion if configured + metric_value: float | None = None + should_stop = False + + if self.criterion_function: + stop_tasks = {} + # Run validation for each model in learner + for model_name in model_names: + if stop_training[model_name]: + continue + criterion_function = copy.deepcopy(self.criterion_function) + criterion_function["kwargs"]["--model_name"] = model_name + stop_task = self._register_task(criterion_function, deps=acl_task) + stop_tasks[model_name] = stop_task + + results = await asyncio.gather(*stop_tasks.values()) + if self.is_stopped: + _stop_reason = "stopped" + break + stops = dict(zip(stop_tasks.keys(), results, strict=False)) + + model_stop: bool + stop_value: float + # The pipeline will stop once all models meet the exit criteria. + should_stop_count: int = 0 + final_results = [] + for model_name, stop in stops.items(): + model_stop, stop_value = self._check_stop_criterion(stop) + final_results.append(stop_value) + if model_stop: + stop_training[model_name] = True + should_stop_count += 1 + print( + f"[Learner {self.learner_name}] Model " + f"{model_name} will stop training" + f" as stop criterion is met at iteration {i}" + ) + + # Store criterion results in state + self.register_state("criterion_values", final_results) + + # Use average of criterion values as the metric value + if final_results: + metric_value = sum(final_results) / len(final_results) + + if should_stop_count == len(stops): + should_stop = True + print( + f"[Learner {self.learner_name}] Stopping " + f"criterion met for all models at iteration {i} " + f"with value: {stop_value}" ) - # Store criterion results in state - self.register_state("criterion_values", final_results) - - # Use average of criterion values as the metric value - if final_results: - metric_value = sum(final_results) / len(final_results) - - if should_stop_count == len(stops): - should_stop = True - logger.info( - f"[Learner {self.learner_name}] Stopping " - f"criterion met for all models at iteration {i} " - f"with value: {stop_value}" - ) - - # Build iteration state - iteration_state = self.build_iteration_state( - iteration=i, - metric_value=metric_value, - should_stop=should_stop, - current_config=learning_config, - ) + # Build iteration state + self._iteration_state = self.build_iteration_state( + iteration=i, + metric_value=metric_value, + should_stop=should_stop, + current_config=learning_config, + ) - # YIELD CONTROL TO CALLER - yield iteration_state + # Notify trackers then yield control to caller + self._notify_trackers_iteration(self._iteration_state) + yield self._iteration_state - # Check if stopping criterion met - if should_stop: - break + # Check if stopping criterion met + if should_stop: + _stop_reason = "criterion_met" + break - iteration_count = i + 1 - futures: list[Any] = [ - _training_stage(learning_config, model_name, iteration_count) - for model_name in model_names - if not stop_training[model_name] - ] - training_tasks = await asyncio.gather(*futures) - if self.is_stopped: - break + iteration_count = i + 1 + futures: list[Any] = [ + _training_stage(learning_config, model_name, iteration_count) + for model_name in model_names + if not stop_training[model_name] + ] + training_tasks = await asyncio.gather(*futures) + if self.is_stopped: + _stop_reason = "stopped" + break - logger.info( - f"[Learner {self.learner_name}] Completed " - f"{iteration_count + 1} iteration(s)" - ) + print(f"[Learner {self.learner_name}] Completed {iteration_count + 1} iteration(s)") + except Exception: + _stop_reason = "error" + raise + finally: + self._notify_trackers_stop(self._iteration_state, _stop_reason) async def teach( self, @@ -357,7 +347,7 @@ async def teach( num_predictions: int = 1, max_iter: int = 0, skip_pre_loop: bool = False, - learning_config: Optional[dict[str, UQLearnerConfig]] = None, + learning_config: dict[str, UQLearnerConfig] | None = None, ) -> list[dict[str, Any]]: """Run sequential UQ active learning loop to completion. @@ -412,17 +402,17 @@ async def teach( class ParallelUQLearner(SeqUQLearner): - """ - Parallel active learner that runs multiple SeqUQLearners concurrently. - This class orchestrates multiple SeqUQLearner instances to run in parallel, - allowing for concurrent exploration of the learning space. Each learner can be - configured independently through per-learner UQLearnerConfig objects. - The parallel learner manages the lifecycle of all sequential learners and collects - their results when all have completed their learning processes. + """Parallel active learner that runs multiple SeqUQLearners concurrently. + + This class orchestrates multiple SeqUQLearner instances to run in parallel, allowing for + concurrent exploration of the learning space. Each learner can be configured independently + through per-learner UQLearnerConfig objects. The parallel learner manages the lifecycle of all + sequential learners and collects their results when all have completed their learning processes. """ def __init__(self, asyncflow: WorkflowEngine) -> None: """Initialize the Parallel Active Learner. + Args: asyncflow: The workflow engine instance used to manage async tasks across all parallel learners. @@ -430,10 +420,9 @@ def __init__(self, asyncflow: WorkflowEngine) -> None: super().__init__(asyncflow) def _create_sequential_learner(self, learner_name: str) -> SeqUQLearner: - """Create a SeqUQLearner instance for a parallel learner. - Creates and configures a new SeqUQLearner with the same base - functions as the parent parallel learner, but with a unique identifier - for logging and debugging purposes. + """Create a SeqUQLearner instance for a parallel learner. Creates and configures a new + SeqUQLearner with the same base functions as the parent parallel learner, but with a unique + identifier for logging and debugging purposes. Args: learner_name: Unique identifier for the learner. @@ -457,8 +446,8 @@ def _create_sequential_learner(self, learner_name: str) -> SeqUQLearner: return sequential_learner def _convert_to_sequential_config( - self, parallel_config: Optional[UQLearnerConfig] - ) -> Optional[UQLearnerConfig]: + self, parallel_config: UQLearnerConfig | None + ) -> UQLearnerConfig | None: """Convert a UQLearnerConfig to a UQLearnerConfig. Note: This method currently performs a direct copy as both parallel and sequential learners use the same UQLearnerConfig type. This method exists @@ -494,13 +483,17 @@ async def start( num_predictions: int = 1, max_iter: int = 0, skip_pre_loop: bool = False, - learner_configs: Optional[dict[str, Optional[UQLearnerConfig]]] = None, - ) -> list[Any]: + learner_configs: dict[str, UQLearnerConfig | None] | None = None, + ) -> AsyncIterator[IterationState]: """Run parallel UQ active learning by launching multiple SeqUQLearners. Orchestrates multiple SeqUQLearner instances to run concurrently, - each with potentially different configurations. All learners run - independently and their results are collected when all have completed. + each with potentially different configurations. States are streamed in + real time as each learner completes an iteration — use ``async for`` to + consume them. + + Each yielded ``IterationState`` includes a ``learner_id`` field (str) + set to the learner's name. Args: learner_names: list of learner names to run concurrently. @@ -515,14 +508,20 @@ async def start( If provided, the length must match the number of elements in learner_names. - Returns: - list containing the final IterationState from each learner, in the - same order as the learners were launched. + Yields: + IterationState for each iteration of each learner, in arrival order. + Each state has ``learner_id`` set to the learner's name (str). Raises: ValueError: If required base functions are not set. ValueError: If neither max_iter nor criterion_function is provided. ValueError: If learner_configs length doesn't match learner_names. + Exception: Re-raises any exception from a learner after all learners finish. + + Example:: + + async for state in learner.start(learner_names=["a", "b"], model_names=[...]): + print(f"Learner {state.learner_id}, iter {state.iteration}: {state.metric_value}") """ # Validate base functions are set if ( @@ -530,85 +529,60 @@ async def start( or not self.training_function or not self.active_learn_function ): - raise ValueError( - "Simulation, Training, and Active Learning functions must be set!" - ) + raise ValueError("Simulation, Training, and Active Learning functions must be set!") if not max_iter and not self.criterion_function: - raise ValueError( - "Either max_iter or stop_criterion_function must be provided." - ) + raise ValueError("Either max_iter or stop_criterion_function must be provided.") # Prepare learner configurations learner_configs = learner_configs or {name: None for name in learner_names} if len(learner_configs) != len(learner_names): raise ValueError("learner_configs length must match learner_names") - logger.info( - f"Starting Parallel UQ Active Learning with {len(learner_names)} learners" - ) - - async def _run_sequential_learner(learner_name: str) -> Any: - """Run a single SeqUQLearner. - - Internal async function that manages the lifecycle of a single - SeqUQLearner within the parallel learning context. - - Args: - learner_name: Unique identifier for this learner instance. - - Returns: - The final IterationState from the sequential learner. - - Raises: - Exception: Re-raises any exception from the sequential learner - with additional context about which learner failed. - """ - try: - # Create and configure the sequential learner - sequential_learner: SeqUQLearner = self._create_sequential_learner( - learner_name - ) - - # Convert parallel config to sequential config - sequential_config: Optional[UQLearnerConfig] = ( - self._convert_to_sequential_config(learner_configs[learner_name]) - ) - logger.info(f"[Parallel-Learner-{learner_name}] Starting sequential learning") - - # Run the sequential learner by iterating through start() - final_state = None - async for state in sequential_learner.start( - model_names=model_names, - num_predictions=num_predictions, - max_iter=max_iter, - skip_pre_loop=skip_pre_loop, - learning_config=sequential_config, - ): - final_state = state - if self.is_stopped: - sequential_learner.stop() - - # Book keep the iteration value from each learner - self.metric_values_per_iteration[f"learner-{learner_name}"] = ( - sequential_learner.metric_values_per_iteration - ) - self.uncertainty_values_per_iteration[f"learner-{learner_name}"] = ( - sequential_learner.uncertainty_values_per_iteration - ) - return final_state - - except Exception as e: - logger.error(f"[Parallel-Learner-{learner_name}] Failed with error: {e}") - raise - - # Submit all learners asynchronously - futures: list[Any] = [ - _run_sequential_learner(learner_name) for learner_name in learner_names - ] - - # Wait for all learners to complete and collect results - return await asyncio.gather(*futures) + print(f"Starting Parallel UQ Active Learning with {len(learner_names)} learners") + + # Factory required: plain closure in a loop would capture the same variable reference. + def make_run_fn(learner_name: str): + async def run_learner(queue: asyncio.Queue) -> None: + try: + sequential_learner: SeqUQLearner = self._create_sequential_learner(learner_name) + print(f"[Parallel-Learner-{learner_name}] Starting sequential learning") + async for state in sequential_learner.start( + model_names=model_names, + num_predictions=num_predictions, + max_iter=max_iter, + skip_pre_loop=skip_pre_loop, + learning_config=learner_configs[learner_name], + ): + if self.is_stopped: + sequential_learner.stop() + await queue.put( + ("state", dataclasses.replace(state, learner_id=learner_name)) + ) + self.metric_values_per_iteration[f"learner-{learner_name}"] = ( + sequential_learner.metric_values_per_iteration + ) + self.uncertainty_values_per_iteration[f"learner-{learner_name}"] = ( + sequential_learner.uncertainty_values_per_iteration + ) + except Exception as e: + print(f"[Parallel-Learner-{learner_name}] Failed with error: {e}") + await queue.put(("error", e)) + finally: + await queue.put(("done", None)) + + return run_learner + + _stop_reason = "max_iter_reached" + try: + async for state in _stream_parallel([make_run_fn(name) for name in learner_names]): + self._notify_trackers_iteration(state) + yield state + except Exception: + _stop_reason = "error" + raise + finally: + self._notify_trackers_stop(self._iteration_state, _stop_reason) async def teach( self, @@ -617,7 +591,7 @@ async def teach( num_predictions: int = 1, max_iter: int = 0, skip_pre_loop: bool = False, - learner_configs: Optional[dict[str, Optional[UQLearnerConfig]]] = None, + learner_configs: dict[str, UQLearnerConfig | None] | None = None, ) -> list[Any]: """Run parallel UQ active learning loop to completion. @@ -634,23 +608,21 @@ async def teach( learner_configs: Configuration for each learner. Returns: - List of results from each learner (in old format for backward - compatibility). + List of final IterationState from each learner, in learner_names order. """ warnings.warn( - "teach() is deprecated and will be removed in a future version. " - "Use start() instead.", + "teach() is deprecated and will be removed in a future version. Use start() instead.", DeprecationWarning, stacklevel=2, ) - - # Call start() and return the final states directly - # The old teach() returned the final states from each learner - return await self.start( + final_states: dict[str, IterationState | None] = {} + async for state in self.start( learner_names=learner_names, model_names=model_names, num_predictions=num_predictions, max_iter=max_iter, skip_pre_loop=skip_pre_loop, learner_configs=learner_configs, - ) + ): + final_states[state.learner_id] = state + return [final_states.get(name) for name in learner_names] diff --git a/rose/uq/uq_learner.py b/rose/uq/uq_learner.py index 3f01b531..2b3387f8 100644 --- a/rose/uq/uq_learner.py +++ b/rose/uq/uq_learner.py @@ -1,14 +1,12 @@ -import logging +from collections.abc import Callable from functools import wraps -from typing import Any, Callable, Optional, Union +from typing import Any import typeguard from radical.asyncflow import WorkflowEngine from ..learner import Learner, LearnerConfig, TaskConfig -logger = logging.getLogger(__name__) - class UQLearnerConfig(LearnerConfig): """ @@ -18,16 +16,14 @@ class UQLearnerConfig(LearnerConfig): or a dictionary mapping iteration numbers to TaskConfig objects. """ - uncertainty: Optional[Union[TaskConfig, dict[int, TaskConfig]]] = None + uncertainty: TaskConfig | dict[int, TaskConfig] | None = None class UQLearner(Learner): - """UQ active learner that runs iterations one after another. - This class implements a sequential active learning approach based - on Uncertainty Quantification. - Each iteration consists of simulation, a set of training and prediction steps, - and active learning phases executed in sequence. - The learner can be configured with per-iteration parameters using UQLearnerConfig. + """UQ active learner that runs iterations one after another. This class implements a sequential + active learning approach based on Uncertainty Quantification. Each iteration consists of + simulation, a set of training and prediction steps, and active learning phases executed in + sequence. The learner can be configured with per-iteration parameters using UQLearnerConfig. Attributes: learner_name (Optional[str]) : Identifier for the learner. @@ -42,7 +38,6 @@ def __init__(self, asyncflow: WorkflowEngine) -> None: Args: asyncflow: The workflow engine instance for managing async tasks. - """ super().__init__(asyncflow, register_and_submit=False) @@ -135,9 +130,7 @@ def _check_uncertainty(self, uncertainty_task_result: Any) -> tuple[bool, float] try: uncertainty_value: float = float(uncertainty_task_result) except Exception as e: - raise Exception( - f"Failed to obtain a numerical value from criterion task: {e}" - ) from e + raise Exception(f"Failed to obtain a numerical value from criterion task: {e}") from e # check if the metric value is a number if isinstance(uncertainty_value, (float, int)): @@ -148,20 +141,15 @@ def _check_uncertainty(self, uncertainty_task_result: Any) -> tuple[bool, float] self.uncertainty_values_per_iteration[self.iteration] = uncertainty_value self.iteration += 1 - if self.compare_metric( - uq_metric_name, uncertainty_value, threshold, operator - ): - logger.info( + if self.compare_metric(uq_metric_name, uncertainty_value, threshold, operator): + print( f"Stop uncertainty metric: {uq_metric_name} " f"is met with value of: {uncertainty_value} " ". Breaking the active learning loop" ) return True, uncertainty_value else: - logger.info( - f"Uncertainty metric: {uq_metric_name} " - f"is not met yet ({uncertainty_value})." - ) + print(f"Uncertainty metric: {uq_metric_name} is not met yet ({uncertainty_value}).") return False, uncertainty_value else: raise TypeError( diff --git a/rose/uq/uq_scorer.py b/rose/uq/uq_scorer.py index a7fd0229..d094ee5b 100644 --- a/rose/uq/uq_scorer.py +++ b/rose/uq/uq_scorer.py @@ -5,7 +5,7 @@ def register_uq(name): - """Decorator to register a UQ metric""" + """Decorator to register a UQ metric.""" def decorator(func): UQ_REGISTRY[name] = func @@ -26,7 +26,7 @@ def __init__(self, task_type): # # ***************************** def _validate_inputs(self, mc_preds, y_true=None): - """Safeguard to check input dimensions""" + """Safeguard to check input dimensions.""" if not isinstance(mc_preds, np.ndarray): _type = type(mc_preds) try: @@ -59,9 +59,7 @@ def _validate_inputs(self, mc_preds, y_true=None): try: y_true = np.array(y_true) except Exception as err: - raise TypeError( - f"Fail to convert {type(y_true)} y_true to numpy array" - ) from err + raise TypeError(f"Fail to convert {type(y_true)} y_true to numpy array") from err if self.task_type == "classification": if y_true.ndim > 2: y_true = np.squeeze(y_true) @@ -112,9 +110,7 @@ def variation_ratio(self, mc_preds): mc_preds, _ = self._validate_inputs(mc_preds) n_mc_samples = mc_preds.shape[0] votes = np.argmax(mc_preds, axis=2) # [n_mc_samples, N] - mode_vote = np.apply_along_axis( - lambda x: np.bincount(x).argmax(), axis=0, arr=votes - ) + mode_vote = np.apply_along_axis(lambda x: np.bincount(x).argmax(), axis=0, arr=votes) mode_count = np.sum(votes == mode_vote, axis=0) vr = 1.0 - mode_count / n_mc_samples return vr @@ -159,17 +155,14 @@ def negative_log_likelihood(self, mc_preds, y_true): else: mean_pred = np.mean(mc_preds, axis=0).squeeze() var_pred = np.var(mc_preds, axis=0).squeeze() + 1e-8 - nll = ( - 0.5 * np.log(2 * np.pi * var_pred) - + 0.5 * ((y_true - mean_pred) ** 2) / var_pred - ) + nll = 0.5 * np.log(2 * np.pi * var_pred) + 0.5 * ((y_true - mean_pred) ** 2) / var_pred return nll # # ***************************** def compute_uncertainty(self, mc_preds, y_true=None): - """Compute all registered UQ metrics""" + """Compute all registered UQ metrics.""" mc_preds, y_true = self._validate_inputs(mc_preds, y_true) results = {} @@ -189,8 +182,7 @@ def compute_uncertainty(self, mc_preds, y_true=None): # # ***************************** def select_top_uncertain(self, mc_preds, k=10, metric=None, y_true=None): - """ - Select top-k most uncertain samples according to a registered metric. + """Select top-k most uncertain samples according to a registered metric. Args: mc_preds: numpy array of MC predictions @@ -214,8 +206,7 @@ def select_top_uncertain(self, mc_preds, k=10, metric=None, y_true=None): if metric not in UQ_REGISTRY: raise ValueError( - f"Metric '{metric}' is not registered. " - f"Available: {list(UQ_REGISTRY.keys())}" + f"Metric '{metric}' is not registered. Available: {list(UQ_REGISTRY.keys())}" ) func = UQ_REGISTRY[metric] diff --git a/tests/integration/test_run_parallel_learner.py b/tests/integration/test_run_parallel_learner.py index b0087cd4..c180ccc9 100644 --- a/tests/integration/test_run_parallel_learner.py +++ b/tests/integration/test_run_parallel_learner.py @@ -1,7 +1,8 @@ from concurrent.futures import ThreadPoolExecutor import pytest -from radical.asyncflow import ConcurrentExecutionBackend, WorkflowEngine +from radical.asyncflow import WorkflowEngine +from rhapsody.backends import ConcurrentExecutionBackend from rose.al.active_learner import ParallelActiveLearner from rose.metrics import MEAN_SQUARED_ERROR_MSE @@ -31,10 +32,19 @@ async def active_learn(sim, trained_model): async def check_mse(*args): return 0.05 # Return a metric value below threshold - await learner.start(parallel_learners=5, max_iter=2) + states = [] + async for state in learner.start(parallel_learners=5, max_iter=2): + states.append(state) - scores = learner.get_metric_results() + # Each learner stops after 1 iteration because criterion (0.05) < threshold (0.1) + assert len(states) > 0 + assert all(state.learner_id is not None for state in states) + assert {state.learner_id for state in states} == {0, 1, 2, 3, 4} + scores = learner.get_metric_results() assert scores != {} + # Verify per-learner metric keys are present + for i in range(5): + assert f"learner-{i}" in scores await learner.shutdown() diff --git a/tests/integration/test_run_rl_par_learner.py b/tests/integration/test_run_rl_par_learner.py index 0573b279..ad244013 100644 --- a/tests/integration/test_run_rl_par_learner.py +++ b/tests/integration/test_run_rl_par_learner.py @@ -1,7 +1,8 @@ from concurrent.futures import ThreadPoolExecutor import pytest -from radical.asyncflow import ConcurrentExecutionBackend, WorkflowEngine +from radical.asyncflow import WorkflowEngine +from rhapsody.backends import ConcurrentExecutionBackend from rose.metrics import GREATER_THAN_THRESHOLD from rose.rl.reinforcement_learner import ParallelReinforcementLearner @@ -30,10 +31,17 @@ async def update(data, *args): async def check_reward(val, *args): return val > 2 - await rl.learn(parallel_learners=5, max_iter=2) + states = [] + async for state in rl.start(parallel_learners=5, max_iter=2): + states.append(state) - scores = rl.get_metric_results() + assert len(states) > 0 + assert all(state.learner_id is not None for state in states) + assert {state.learner_id for state in states} == {0, 1, 2, 3, 4} + scores = rl.get_metric_results() assert scores != {} + for i in range(5): + assert f"learner-{i}" in scores await rl.shutdown() diff --git a/tests/integration/test_run_rl_seq_learner.py b/tests/integration/test_run_rl_seq_learner.py index 31e5b668..f61f4495 100644 --- a/tests/integration/test_run_rl_seq_learner.py +++ b/tests/integration/test_run_rl_seq_learner.py @@ -1,7 +1,8 @@ from concurrent.futures import ThreadPoolExecutor import pytest -from radical.asyncflow import ConcurrentExecutionBackend, WorkflowEngine +from radical.asyncflow import WorkflowEngine +from rhapsody.backends import ConcurrentExecutionBackend from rose.metrics import GREATER_THAN_THRESHOLD from rose.rl.reinforcement_learner import SequentialReinforcementLearner @@ -30,10 +31,14 @@ async def update(data, *args): async def check_reward(val, *args): return val > 2 - await rl.learn(max_iter=1) + states = [] + async for state in rl.start(max_iter=1): + states.append(state) - scores = rl.get_metric_results() + assert len(states) == 1 + assert states[0].iteration == 0 + scores = rl.get_metric_results() assert scores != {} await rl.shutdown() diff --git a/tests/integration/test_run_sequential_learner.py b/tests/integration/test_run_sequential_learner.py index 902f73c2..d7a75efd 100644 --- a/tests/integration/test_run_sequential_learner.py +++ b/tests/integration/test_run_sequential_learner.py @@ -1,7 +1,8 @@ from concurrent.futures import ThreadPoolExecutor import pytest -from radical.asyncflow import ConcurrentExecutionBackend, WorkflowEngine +from radical.asyncflow import WorkflowEngine +from rhapsody.backends import ConcurrentExecutionBackend from rose.al.active_learner import SequentialActiveLearner from rose.metrics import MEAN_SQUARED_ERROR_MSE @@ -25,9 +26,7 @@ async def training(data): async def active_learn(sim, trained_model): return abs(trained_model["mean"] - 2.5) - @acl.as_stop_criterion( - metric_name=MEAN_SQUARED_ERROR_MSE, threshold=0.1, as_executable=False - ) + @acl.as_stop_criterion(metric_name=MEAN_SQUARED_ERROR_MSE, threshold=0.1, as_executable=False) async def check_mse(*args): return 0.05 # Return a metric value below threshold diff --git a/tests/integration/test_run_uq_learner.py b/tests/integration/test_run_uq_learner.py index 75fd4fb5..86533e3f 100644 --- a/tests/integration/test_run_uq_learner.py +++ b/tests/integration/test_run_uq_learner.py @@ -3,7 +3,8 @@ import numpy as np import pytest -from radical.asyncflow import ConcurrentExecutionBackend, WorkflowEngine +from radical.asyncflow import WorkflowEngine +from rhapsody.backends import ConcurrentExecutionBackend from rose.metrics import MEAN_SQUARED_ERROR_MSE, PREDICTIVE_ENTROPY from rose.uq import UQ_REGISTRY, register_uq @@ -50,16 +51,19 @@ async def check_uq(*args, **kwargs): # Calculate mean or just return a simple value for testing return 0.5 - results = await learner.start( + states = [] + async for state in learner.start( learner_names=["l1", "l2"], learner_configs={"l1": None, "l2": None}, model_names=["m1"], max_iter=2, - ) + ): + states.append(state) - # Verify we got results from both learners - assert len(results) == 2 - assert all(state is not None for state in results) + # criterion returns 0.05 < threshold 0.1, so each learner stops after 1 iteration → 2 states + assert len(states) == 2 + assert all(state is not None for state in states) + assert {state.learner_id for state in states} == {"l1", "l2"} scores = learner.get_metric_results() uq_scores = learner.get_uncertainty_results() @@ -111,18 +115,14 @@ async def test_uqlearner_runs_with_mock_functions(): # Patch internal helpers learner._get_iteration_task_config = MagicMock(return_value={"kwargs": {}}) - learner._register_task = AsyncMock( - side_effect=lambda config, deps=None: {"result": 42} - ) + learner._register_task = AsyncMock(side_effect=lambda config, deps=None: {"result": 42}) learner._check_stop_criterion = MagicMock(return_value=(True, 0.1)) learner._check_uncertainty = MagicMock(return_value=(False, 0.5)) learner.build_iteration_state = MagicMock() # Use the new start() method iteration_count = 0 - async for state in learner.start( - model_names=["modelA"], max_iter=1, num_predictions=1 - ): + async for state in learner.start(model_names=["modelA"], max_iter=1, num_predictions=1): iteration_count += 1 # Verify the iteration ran assert state is not None diff --git a/tests/integration/tracking/test_clearml_tracker.py b/tests/integration/tracking/test_clearml_tracker.py new file mode 100644 index 00000000..5b858fb0 --- /dev/null +++ b/tests/integration/tracking/test_clearml_tracker.py @@ -0,0 +1,405 @@ +"""Unit tests for ClearMLTracker. + +clearml is never actually imported — every test injects a MagicMock into sys.modules before +importing the tracker class. +""" + +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from rose.learner import IterationState +from rose.tracking import CriterionManifest, PipelineManifest, TaskManifest + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _make_simple_manifest(): + return PipelineManifest( + learner_type="SequentialActiveLearner", + tasks={ + "simulation": TaskManifest( + func_name="sim", + func_module="mod", + as_executable=False, + ), + "training": TaskManifest( + func_name="train", + func_module="mod", + as_executable=False, + log_params={"num_gpus": 2}, + ), + }, + criterion=CriterionManifest( + func_name="check", + func_module="mod", + as_executable=False, + metric_name="mean_squared_error_mse", + threshold=0.01, + operator="<", + ), + ) + + +def _make_mock_clearml(): + """Return a dict mirroring the fixture structure for use inside with blocks.""" + mock_task_instance = MagicMock() + mock_logger = MagicMock() + mock_task_instance.get_logger.return_value = mock_logger + + mock_task_cls = MagicMock() + mock_task_cls.init.return_value = mock_task_instance + + mock_clearml_module = MagicMock() + mock_clearml_module.Task = mock_task_cls + + return { + "module": mock_clearml_module, + "Task": mock_task_cls, + "task": mock_task_instance, + "logger": mock_logger, + } + + +# --------------------------------------------------------------------------- +# TestClearMLTrackerInit +# --------------------------------------------------------------------------- + + +class TestClearMLTrackerInit: + def test_raises_import_error_when_clearml_missing(self): + with patch.dict(sys.modules, {"clearml": None}): + with pytest.raises(ImportError): + from rose.integrations.clearml_tracker import ClearMLTracker # noqa: F401 + + ClearMLTracker(project_name="p", task_name="t") + + def test_init_succeeds_when_clearml_available(self): + mocks = _make_mock_clearml() + with patch.dict(sys.modules, {"clearml": mocks["module"]}): + from rose.integrations.clearml_tracker import ClearMLTracker + + tracker = ClearMLTracker(project_name="test-project", task_name="test-task") + assert tracker is not None + + +# --------------------------------------------------------------------------- +# TestClearMLTrackerOnStart +# --------------------------------------------------------------------------- + + +class TestClearMLTrackerOnStart: + @pytest.fixture + def mock_clearml(self): + mocks = _make_mock_clearml() + with patch.dict(sys.modules, {"clearml": mocks["module"]}): + yield mocks + + @pytest.fixture + def tracker(self, mock_clearml): + from rose.integrations.clearml_tracker import ClearMLTracker + + return ClearMLTracker(project_name="test-project", task_name="test-task") + + @pytest.fixture + def simple_manifest(self): + return _make_simple_manifest() + + def test_inits_clearml_task(self, tracker, mock_clearml, simple_manifest): + tracker.on_start(simple_manifest) + mock_clearml["Task"].init.assert_called_once_with( + project_name="test-project", + task_name="test-task", + ) + + def test_gets_logger(self, tracker, mock_clearml, simple_manifest): + tracker.on_start(simple_manifest) + mock_clearml["task"].get_logger.assert_called_once() + + def test_connects_learner_type(self, tracker, mock_clearml, simple_manifest): + tracker.on_start(simple_manifest) + connected_params = mock_clearml["task"].connect.call_args[0][0] + assert "learner_type" in connected_params + assert connected_params["learner_type"] == "SequentialActiveLearner" + + def test_connects_criterion_params(self, tracker, mock_clearml, simple_manifest): + tracker.on_start(simple_manifest) + connected_params = mock_clearml["task"].connect.call_args[0][0] + assert connected_params["criterion/metric_name"] == "mean_squared_error_mse" + assert connected_params["criterion/threshold"] == 0.01 + + def test_connects_criterion_operator(self, tracker, mock_clearml, simple_manifest): + tracker.on_start(simple_manifest) + connected_params = mock_clearml["task"].connect.call_args[0][0] + assert connected_params["criterion/operator"] == "<" + + def test_connects_task_params(self, tracker, mock_clearml, simple_manifest): + tracker.on_start(simple_manifest) + connected_params = mock_clearml["task"].connect.call_args[0][0] + assert "task/simulation/as_executable" in connected_params + + def test_no_criterion_connects_none_values(self, tracker, mock_clearml): + manifest = PipelineManifest( + learner_type="SequentialActiveLearner", + tasks={}, + criterion=None, + ) + tracker.on_start(manifest) + connected_params = mock_clearml["task"].connect.call_args[0][0] + assert connected_params["criterion/metric_name"] is None + assert connected_params["criterion/threshold"] is None + + +# --------------------------------------------------------------------------- +# TestClearMLTrackerOnIteration +# --------------------------------------------------------------------------- + + +class TestClearMLTrackerOnIteration: + @pytest.fixture + def mock_clearml(self): + mocks = _make_mock_clearml() + with patch.dict(sys.modules, {"clearml": mocks["module"]}): + yield mocks + + @pytest.fixture + def tracker(self, mock_clearml): + from rose.integrations.clearml_tracker import ClearMLTracker + + t = ClearMLTracker(project_name="test-project", task_name="test-task") + # Simulate on_start having been called + t._task = mock_clearml["task"] + t._logger = mock_clearml["logger"] + return t + + @pytest.fixture + def iteration_state(self): + return IterationState( + iteration=3, + metric_value=0.05, + metric_name="mse", + state={"n_labeled": 25, "train_loss": 0.03, "label": "not_numeric"}, + ) + + def test_reports_metric_scalar(self, tracker, mock_clearml, iteration_state): + tracker.on_iteration(iteration_state) + mock_clearml["logger"].report_scalar.assert_any_call( + title="mse", + series="value", + value=0.05, + iteration=3, + ) + + def test_reports_state_scalars(self, tracker, mock_clearml, iteration_state): + tracker.on_iteration(iteration_state) + reported_titles = [ + c[1]["title"] for c in mock_clearml["logger"].report_scalar.call_args_list + ] + assert "n_labeled" in reported_titles + assert "train_loss" in reported_titles + + def test_skips_non_numeric_state(self, tracker, mock_clearml, iteration_state): + tracker.on_iteration(iteration_state) + reported_titles = [ + c[1]["title"] for c in mock_clearml["logger"].report_scalar.call_args_list + ] + assert "label" not in reported_titles + + def test_skips_when_no_logger(self, tracker, mock_clearml, iteration_state): + tracker._logger = None + # Should not raise + tracker.on_iteration(iteration_state) + mock_clearml["logger"].report_scalar.assert_not_called() + + def test_parallel_learner_uses_learner_id_as_series(self, tracker, mock_clearml): + state = IterationState( + iteration=2, + metric_value=0.05, + metric_name="mse", + learner_id="B", + state={}, + ) + tracker.on_iteration(state) + mock_clearml["logger"].report_scalar.assert_called_once_with( + title="mse", + series="B", + value=0.05, + iteration=2, + ) + + def test_sequential_learner_uses_value_as_series(self, tracker, mock_clearml): + state = IterationState( + iteration=0, + metric_value=0.1, + metric_name="mse", + learner_id=None, + state={}, + ) + tracker.on_iteration(state) + call_kwargs = mock_clearml["logger"].report_scalar.call_args[1] + assert call_kwargs["series"] == "value" + + def test_logs_config_kwargs_as_scalars(self, tracker, mock_clearml): + from rose.learner import LearnerConfig, TaskConfig + + state = IterationState( + iteration=10, + metric_value=0.01, + metric_name="mse", + state={}, + current_config=LearnerConfig( + training=TaskConfig(kwargs={"length_scale": 0.2, "noise_level": 0.01}) + ), + ) + tracker.on_iteration(state) + reported_titles = [ + c[1]["title"] for c in mock_clearml["logger"].report_scalar.call_args_list + ] + assert "config/training/length_scale" in reported_titles + assert "config/training/noise_level" in reported_titles + # Verify values + for call in mock_clearml["logger"].report_scalar.call_args_list: + if call[1]["title"] == "config/training/length_scale": + assert call[1]["value"] == 0.2 + assert call[1]["iteration"] == 10 + + def test_skips_cli_style_config_kwargs(self, tracker, mock_clearml): + from rose.learner import LearnerConfig, TaskConfig + + state = IterationState( + iteration=0, + metric_value=None, + metric_name=None, + state={}, + current_config=LearnerConfig( + training=TaskConfig(kwargs={"--learner_name": "ensemble-A", "lr": 0.001}) + ), + ) + tracker.on_iteration(state) + reported_titles = [ + c[1]["title"] for c in mock_clearml["logger"].report_scalar.call_args_list + ] + assert not any("learner_name" in t for t in reported_titles) + assert any("lr" in t for t in reported_titles) + + def test_no_config_skips_config_scalars(self, tracker, mock_clearml): + state = IterationState( + iteration=0, + metric_value=None, + metric_name=None, + state={}, + current_config=None, + ) + tracker.on_iteration(state) + reported_titles = [ + c[1]["title"] for c in mock_clearml["logger"].report_scalar.call_args_list + ] + assert not any("config/" in t for t in reported_titles) + + def test_logs_string_config_kwargs_to_current_config_section(self, tracker, mock_clearml): + from rose.learner import LearnerConfig, TaskConfig + + state = IterationState( + iteration=5, + metric_value=None, + metric_name=None, + state={}, + current_config=LearnerConfig( + training=TaskConfig(kwargs={"kernel_type": "RBF+WhiteKernel", "length_scale": 0.2}) + ), + ) + tracker.on_iteration(state) + mock_clearml["task"].connect.assert_called_once_with( + {"training/kernel_type": "RBF+WhiteKernel"}, name="current_config" + ) + # numeric value goes to report_scalar, not connect + reported_titles = [ + c[1]["title"] for c in mock_clearml["logger"].report_scalar.call_args_list + ] + assert "config/training/length_scale" in reported_titles + + def test_learner_names_maps_int_id_to_name(self, mock_clearml): + from rose.integrations.clearml_tracker import ClearMLTracker + + t = ClearMLTracker( + project_name="p", task_name="t", learner_names=["ensemble-A", "ensemble-B"] + ) + t._task = mock_clearml["task"] + t._logger = mock_clearml["logger"] + + state = IterationState( + iteration=1, metric_value=0.05, metric_name="mse", learner_id=1, state={} + ) + t.on_iteration(state) + mock_clearml["logger"].report_scalar.assert_called_once_with( + title="mse", series="ensemble-B", value=0.05, iteration=1 + ) + + def test_integer_learner_id_zero_uses_string_zero_not_value(self, tracker, mock_clearml): + state = IterationState( + iteration=0, metric_value=0.1, metric_name="mse", learner_id=0, state={} + ) + tracker.on_iteration(state) + call_kwargs = mock_clearml["logger"].report_scalar.call_args[1] + assert call_kwargs["series"] == "0" + assert call_kwargs["series"] != "value" + + +# --------------------------------------------------------------------------- +# TestClearMLTrackerOnStop +# --------------------------------------------------------------------------- + + +class TestClearMLTrackerOnStop: + @pytest.fixture + def mock_clearml(self): + mocks = _make_mock_clearml() + with patch.dict(sys.modules, {"clearml": mocks["module"]}): + yield mocks + + @pytest.fixture + def tracker(self, mock_clearml): + from rose.integrations.clearml_tracker import ClearMLTracker + + t = ClearMLTracker(project_name="test-project", task_name="test-task") + t._task = mock_clearml["task"] + t._logger = mock_clearml["logger"] + return t + + def test_adds_stop_reason_tag(self, tracker, mock_clearml): + tracker.on_stop(IterationState(iteration=5), "criterion_met") + mock_clearml["task"].add_tags.assert_any_call(["stop:criterion_met"]) + + def test_adds_final_iter_tag(self, tracker, mock_clearml): + tracker.on_stop(IterationState(iteration=5), "criterion_met") + mock_clearml["task"].add_tags.assert_any_call(["final_iter:5"]) + + def test_closes_task(self, tracker, mock_clearml): + tracker.on_stop(IterationState(iteration=1), "max_iter_reached") + mock_clearml["task"].close.assert_called_once() + + def test_marks_failed_on_error(self, tracker, mock_clearml): + tracker.on_stop(None, "error") + mock_clearml["task"].mark_failed.assert_called_once_with(status_reason="error during run") + mock_clearml["task"].close.assert_called_once() + + def test_does_not_mark_failed_on_normal_stop(self, tracker, mock_clearml): + tracker.on_stop(IterationState(iteration=3), "criterion_met") + mock_clearml["task"].mark_failed.assert_not_called() + + def test_no_final_state_skips_iter_tag(self, tracker, mock_clearml): + tracker.on_stop(None, "stopped") + all_tag_calls = mock_clearml["task"].add_tags.call_args_list + # Only one add_tags call (for stop reason), no final_iter tag + assert len(all_tag_calls) == 1 + assert all_tag_calls[0][0][0] == ["stop:stopped"] + + def test_skips_when_no_task(self, tracker, mock_clearml): + tracker._task = None + # Should not raise + tracker.on_stop(IterationState(iteration=1), "error") + mock_clearml["task"].add_tags.assert_not_called() + mock_clearml["task"].close.assert_not_called() diff --git a/tests/integration/tracking/test_mlflow_tracker.py b/tests/integration/tracking/test_mlflow_tracker.py new file mode 100644 index 00000000..f84eb169 --- /dev/null +++ b/tests/integration/tracking/test_mlflow_tracker.py @@ -0,0 +1,343 @@ +"""Unit tests for MLflowTracker. + +mlflow is never actually imported — every test injects a MagicMock into sys.modules before importing +the tracker class. +""" + +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from rose.learner import IterationState +from rose.tracking import CriterionManifest, PipelineManifest, TaskManifest + +# --------------------------------------------------------------------------- +# Shared fixtures (module-level helpers used by multiple classes) +# --------------------------------------------------------------------------- + + +def _make_simple_manifest(): + return PipelineManifest( + learner_type="SequentialActiveLearner", + tasks={ + "simulation": TaskManifest( + func_name="sim", + func_module="mod", + as_executable=False, + ), + "training": TaskManifest( + func_name="train", + func_module="mod", + as_executable=False, + log_params={"num_gpus": 2}, + ), + }, + criterion=CriterionManifest( + func_name="check", + func_module="mod", + as_executable=False, + metric_name="mean_squared_error_mse", + threshold=0.01, + operator="<", + ), + ) + + +# --------------------------------------------------------------------------- +# TestMLflowTrackerInit +# --------------------------------------------------------------------------- + + +class TestMLflowTrackerInit: + def test_raises_import_error_when_mlflow_missing(self): + with patch.dict(sys.modules, {"mlflow": None, "mlflow.models": None}): + with pytest.raises(ImportError, match="pip install rose\\[mlflow\\]"): + from rose.integrations.mlflow_tracker import MLflowTracker # noqa: F401 + + MLflowTracker(experiment_name="x") + + def test_init_succeeds_when_mlflow_available(self): + mock_mlflow = MagicMock() + with patch.dict(sys.modules, {"mlflow": mock_mlflow, "mlflow.models": MagicMock()}): + from rose.integrations.mlflow_tracker import MLflowTracker + + tracker = MLflowTracker(experiment_name="test-exp", run_name="test-run") + assert tracker is not None + + +# --------------------------------------------------------------------------- +# TestMLflowTrackerOnStart +# --------------------------------------------------------------------------- + + +class TestMLflowTrackerOnStart: + @pytest.fixture + def mock_mlflow(self): + mock = MagicMock() + mock_run = MagicMock() + mock.start_run.return_value = mock_run + with patch.dict(sys.modules, {"mlflow": mock, "mlflow.models": MagicMock()}): + yield mock + + @pytest.fixture + def tracker(self, mock_mlflow): + from rose.integrations.mlflow_tracker import MLflowTracker + + return MLflowTracker(experiment_name="test-exp", run_name="test-run") + + @pytest.fixture + def simple_manifest(self): + return _make_simple_manifest() + + def test_sets_experiment(self, tracker, mock_mlflow, simple_manifest): + tracker.on_start(simple_manifest) + mock_mlflow.set_experiment.assert_called_once_with("test-exp") + + def test_starts_run_with_name(self, tracker, mock_mlflow, simple_manifest): + tracker.on_start(simple_manifest) + mock_mlflow.start_run.assert_called_once_with(run_name="test-run") + + def test_logs_learner_type_param(self, tracker, mock_mlflow, simple_manifest): + tracker.on_start(simple_manifest) + logged_params = mock_mlflow.log_params.call_args[0][0] + assert logged_params["learner_type"] == "SequentialActiveLearner" + + def test_logs_criterion_params(self, tracker, mock_mlflow, simple_manifest): + tracker.on_start(simple_manifest) + logged_params = mock_mlflow.log_params.call_args[0][0] + assert logged_params["criterion/metric_name"] == "mean_squared_error_mse" + assert logged_params["criterion/threshold"] == 0.01 + + def test_logs_criterion_operator(self, tracker, mock_mlflow, simple_manifest): + tracker.on_start(simple_manifest) + logged_params = mock_mlflow.log_params.call_args[0][0] + assert logged_params["criterion/operator"] == "<" + + def test_logs_task_as_executable(self, tracker, mock_mlflow, simple_manifest): + tracker.on_start(simple_manifest) + logged_params = mock_mlflow.log_params.call_args[0][0] + assert logged_params["task.simulation.as_executable"] is False + + def test_logs_task_log_params(self, tracker, mock_mlflow, simple_manifest): + tracker.on_start(simple_manifest) + logged_params = mock_mlflow.log_params.call_args[0][0] + assert logged_params["task.training.num_gpus"] == 2 + + def test_no_criterion_logs_none(self, tracker, mock_mlflow): + manifest = PipelineManifest( + learner_type="SequentialActiveLearner", + tasks={}, + criterion=None, + ) + tracker.on_start(manifest) + logged_params = mock_mlflow.log_params.call_args[0][0] + assert logged_params["criterion/metric_name"] is None + + +# --------------------------------------------------------------------------- +# TestMLflowTrackerOnIteration +# --------------------------------------------------------------------------- + + +class TestMLflowTrackerOnIteration: + @pytest.fixture + def mock_mlflow(self): + mock = MagicMock() + mock.start_run.return_value = MagicMock() + with patch.dict(sys.modules, {"mlflow": mock, "mlflow.models": MagicMock()}): + yield mock + + @pytest.fixture + def tracker(self, mock_mlflow): + from rose.integrations.mlflow_tracker import MLflowTracker + + return MLflowTracker(experiment_name="test-exp", run_name="test-run") + + @pytest.fixture + def iteration_state(self): + return IterationState( + iteration=3, + metric_value=0.05, + metric_name="mse", + state={"n_labeled": 25, "train_loss": 0.03, "label": "not_numeric"}, + ) + + def test_logs_metric_value(self, tracker, mock_mlflow, iteration_state): + tracker.on_iteration(iteration_state) + mock_mlflow.log_metric.assert_any_call("mse", 0.05, step=3) + + def test_logs_scalar_state_values(self, tracker, mock_mlflow, iteration_state): + tracker.on_iteration(iteration_state) + mock_mlflow.log_metric.assert_any_call("n_labeled", 25, step=3) + mock_mlflow.log_metric.assert_any_call("train_loss", 0.03, step=3) + + def test_skips_non_numeric_state_values(self, tracker, mock_mlflow, iteration_state): + tracker.on_iteration(iteration_state) + logged_keys = [c[0][0] for c in mock_mlflow.log_metric.call_args_list] + assert "label" not in logged_keys + + def test_no_metric_value_skips_metric_log(self, tracker, mock_mlflow): + state = IterationState( + iteration=1, + metric_value=None, + metric_name="mse", + state={}, + ) + tracker.on_iteration(state) + logged_keys = [c[0][0] for c in mock_mlflow.log_metric.call_args_list] + assert "mse" not in logged_keys + + def test_uses_fallback_metric_name(self, tracker, mock_mlflow): + state = IterationState( + iteration=2, + metric_value=0.1, + metric_name=None, + state={}, + ) + tracker.on_iteration(state) + mock_mlflow.log_metric.assert_any_call("metric", 0.1, step=2) + + def test_parallel_learner_prefixes_metric_with_learner_id(self, tracker, mock_mlflow): + state = IterationState( + iteration=1, + metric_value=0.05, + metric_name="mse", + learner_id="A", + state={"n_labeled": 10}, + ) + tracker.on_iteration(state) + mock_mlflow.log_metric.assert_any_call("A/mse", 0.05, step=1) + mock_mlflow.log_metric.assert_any_call("A/n_labeled", 10, step=1) + + def test_sequential_learner_no_prefix(self, tracker, mock_mlflow): + state = IterationState( + iteration=0, + metric_value=0.1, + metric_name="mse", + learner_id=None, + state={}, + ) + tracker.on_iteration(state) + logged_keys = [c[0][0] for c in mock_mlflow.log_metric.call_args_list] + assert "mse" in logged_keys + assert not any(k.startswith("/") for k in logged_keys) + + def test_logs_config_kwargs_as_metrics(self, tracker, mock_mlflow): + from rose.learner import LearnerConfig, TaskConfig + + state = IterationState( + iteration=10, + metric_value=0.01, + metric_name="mse", + state={}, + current_config=LearnerConfig( + training=TaskConfig(kwargs={"length_scale": 0.2, "noise_level": 0.01}) + ), + ) + tracker.on_iteration(state) + mock_mlflow.log_metric.assert_any_call("config/training/length_scale", 0.2, step=10) + mock_mlflow.log_metric.assert_any_call("config/training/noise_level", 0.01, step=10) + + def test_skips_cli_style_config_kwargs(self, tracker, mock_mlflow): + from rose.learner import LearnerConfig, TaskConfig + + state = IterationState( + iteration=0, + metric_value=None, + metric_name=None, + state={}, + current_config=LearnerConfig( + training=TaskConfig(kwargs={"--learner_name": "ensemble-A", "lr": 0.001}) + ), + ) + tracker.on_iteration(state) + logged_keys = [c[0][0] for c in mock_mlflow.log_metric.call_args_list] + assert not any("learner_name" in k for k in logged_keys) + assert any("lr" in k for k in logged_keys) + + def test_no_config_skips_config_metrics(self, tracker, mock_mlflow): + state = IterationState( + iteration=0, + metric_value=None, + metric_name=None, + state={}, + current_config=None, + ) + tracker.on_iteration(state) + logged_keys = [c[0][0] for c in mock_mlflow.log_metric.call_args_list] + assert not any("config/" in k for k in logged_keys) + + def test_logs_string_config_kwargs_as_tags(self, tracker, mock_mlflow): + from rose.learner import LearnerConfig, TaskConfig + + state = IterationState( + iteration=10, + metric_value=None, + metric_name=None, + state={}, + current_config=LearnerConfig( + training=TaskConfig(kwargs={"kernel_type": "RBF+WhiteKernel", "length_scale": 0.2}) + ), + ) + tracker.on_iteration(state) + mock_mlflow.set_tag.assert_any_call("config/training/kernel_type", "RBF+WhiteKernel") + # numeric value goes to log_metric, not set_tag + mock_mlflow.log_metric.assert_any_call("config/training/length_scale", 0.2, step=10) + + +# --------------------------------------------------------------------------- +# TestMLflowTrackerOnStop +# --------------------------------------------------------------------------- + + +class TestMLflowTrackerOnStop: + @pytest.fixture + def mock_mlflow(self): + mock = MagicMock() + mock.start_run.return_value = MagicMock() + with patch.dict(sys.modules, {"mlflow": mock, "mlflow.models": MagicMock()}): + yield mock + + @pytest.fixture + def tracker(self, mock_mlflow): + from rose.integrations.mlflow_tracker import MLflowTracker + + t = MLflowTracker(experiment_name="test-exp", run_name="test-run") + t._run = MagicMock() # simulate on_start having been called + return t + + def test_sets_stop_reason_tag(self, tracker, mock_mlflow): + tracker.on_stop(IterationState(iteration=5), "criterion_met") + mock_mlflow.set_tag.assert_any_call("stop_reason", "criterion_met") + + def test_sets_final_iteration_tag(self, tracker, mock_mlflow): + tracker.on_stop(IterationState(iteration=5), "criterion_met") + mock_mlflow.set_tag.assert_any_call("final_iteration", "5") + + def test_ends_run_with_finished_status_on_normal_stop(self, tracker, mock_mlflow): + tracker.on_stop(IterationState(iteration=1), "max_iter_reached") + mock_mlflow.end_run.assert_called_once_with(status="FINISHED") + + def test_ends_run_with_failed_status_on_error(self, tracker, mock_mlflow): + tracker.on_stop(None, "error") + mock_mlflow.end_run.assert_called_once_with(status="FAILED") + + def test_no_final_state_skips_iteration_tag(self, tracker, mock_mlflow): + tracker.on_stop(None, "stopped") + tag_keys = [c[0][0] for c in mock_mlflow.set_tag.call_args_list] + assert "final_iteration" not in tag_keys + + def test_no_run_skips_silently(self, tracker, mock_mlflow): + tracker._run = None + tracker.on_stop(IterationState(iteration=1), "error") # should not raise + mock_mlflow.set_tag.assert_not_called() + mock_mlflow.end_run.assert_not_called() + + @pytest.mark.parametrize( + "reason", + ["criterion_met", "max_iter_reached", "stopped", "error"], + ) + def test_all_stop_reasons(self, tracker, mock_mlflow, reason): + tracker.on_stop(None, reason) + mock_mlflow.set_tag.assert_any_call("stop_reason", reason) diff --git a/tests/unit/test_learner_core.py b/tests/unit/test_learner_core.py new file mode 100644 index 00000000..1c21dc9e --- /dev/null +++ b/tests/unit/test_learner_core.py @@ -0,0 +1,459 @@ +"""Unit tests for core learner primitives: IterationState, LearnerConfig.get_task_config, +Learner base class state/callback machinery, and the _stream_parallel fan-in helper.""" + +import asyncio +import dataclasses +from unittest.mock import MagicMock + +import pytest +from radical.asyncflow import WorkflowEngine + +from rose.learner import ( + IterationState, + Learner, + LearnerConfig, + TaskConfig, + _stream_parallel, +) + +# --------------------------------------------------------------------------- +# IterationState +# --------------------------------------------------------------------------- + + +class TestIterationState: + def test_default_values(self): + state = IterationState(iteration=3) + assert state.iteration == 3 + assert state.metric_name is None + assert state.metric_value is None + assert state.metric_threshold is None + assert state.metric_history == [] + assert state.should_stop is False + assert state.current_config is None + assert state.learner_id is None + assert state.state == {} + + def test_attribute_access_to_state_dict(self): + state = IterationState(iteration=0, state={"loss": 0.5, "accuracy": 0.95}) + assert state.loss == 0.5 + assert state.accuracy == 0.95 + + def test_missing_state_key_returns_none(self): + state = IterationState(iteration=0) + assert state.nonexistent_key is None + + def test_get_with_existing_key(self): + state = IterationState(iteration=0, state={"loss": 0.5}) + assert state.get("loss") == 0.5 + + def test_get_with_missing_key_returns_default(self): + state = IterationState(iteration=0) + assert state.get("missing", "default_val") == "default_val" + + def test_to_dict_contains_all_top_level_fields(self): + state = IterationState( + iteration=2, + metric_name="mse", + metric_value=0.05, + metric_threshold=0.01, + should_stop=True, + learner_id=1, + ) + d = state.to_dict() + assert d["iteration"] == 2 + assert d["metric_name"] == "mse" + assert d["metric_value"] == 0.05 + assert d["metric_threshold"] == 0.01 + assert d["should_stop"] is True + assert d["learner_id"] == 1 + + def test_to_dict_merges_state_dict(self): + state = IterationState( + iteration=0, + state={"labeled_count": 100, "uncertainty": 0.3}, + ) + d = state.to_dict() + assert d["labeled_count"] == 100 + assert d["uncertainty"] == 0.3 + + def test_dataclasses_replace_sets_learner_id(self): + state = IterationState(iteration=5, metric_value=0.1, metric_name="mse") + replaced = dataclasses.replace(state, learner_id=3) + assert replaced.learner_id == 3 + + def test_dataclasses_replace_preserves_all_other_fields(self): + original_state = {"x": 42} + state = IterationState( + iteration=5, + metric_value=0.1, + metric_name="mse", + should_stop=False, + learner_id=None, + state=original_state, + ) + replaced = dataclasses.replace(state, learner_id=7) + assert replaced.iteration == 5 + assert replaced.metric_value == 0.1 + assert replaced.metric_name == "mse" + assert replaced.should_stop is False + assert replaced.state is original_state + + def test_learner_id_accepts_int(self): + state = IterationState(iteration=0, learner_id=0) + assert state.learner_id == 0 + + def test_learner_id_accepts_str(self): + state = IterationState(iteration=0, learner_id="learner-A") + assert state.learner_id == "learner-A" + + def test_learner_id_accepts_none(self): + state = IterationState(iteration=0, learner_id=None) + assert state.learner_id is None + + +# --------------------------------------------------------------------------- +# LearnerConfig.get_task_config +# --------------------------------------------------------------------------- + + +class TestLearnerConfigGetTaskConfig: + def test_returns_none_when_field_is_none(self): + config = LearnerConfig() + assert config.get_task_config("simulation", 0) is None + assert config.get_task_config("training", 5) is None + + def test_returns_taskconfig_directly_for_all_iterations(self): + tc = TaskConfig(kwargs={"--lr": "0.01"}) + config = LearnerConfig(training=tc) + assert config.get_task_config("training", 0) is tc + assert config.get_task_config("training", 5) is tc + assert config.get_task_config("training", 99) is tc + + def test_exact_iteration_match_in_dict(self): + tc_0 = TaskConfig(kwargs={"--n": "100"}) + tc_5 = TaskConfig(kwargs={"--n": "200"}) + config = LearnerConfig(simulation={0: tc_0, 5: tc_5, -1: TaskConfig()}) + assert config.get_task_config("simulation", 0) is tc_0 + assert config.get_task_config("simulation", 5) is tc_5 + + def test_falls_back_to_minus_one_key(self): + default_tc = TaskConfig(kwargs={"--n": "500"}) + config = LearnerConfig(simulation={0: TaskConfig(), -1: default_tc}) + assert config.get_task_config("simulation", 99) is default_tc + assert config.get_task_config("simulation", 1) is default_tc + + def test_returns_none_when_dict_has_no_match_and_no_default(self): + config = LearnerConfig(simulation={0: TaskConfig()}) + assert config.get_task_config("simulation", 7) is None + + def test_works_for_all_field_names(self): + tc = TaskConfig(kwargs={"k": "v"}) + for field in ( + "simulation", + "training", + "active_learn", + "environment", + "update", + "criterion", + ): + config = LearnerConfig(**{field: tc}) + assert config.get_task_config(field, 0) is tc + + +# --------------------------------------------------------------------------- +# Learner base class: state registry, callbacks, build_iteration_state +# --------------------------------------------------------------------------- + + +@pytest.fixture +def learner(): + mock_asyncflow = MagicMock(spec=WorkflowEngine) + return Learner(mock_asyncflow) + + +class TestLearnerStateRegistry: + def test_register_state_stores_value(self, learner): + learner.register_state("loss", 0.5) + assert learner.get_state("loss") == 0.5 + + def test_get_state_returns_default_when_missing(self, learner): + assert learner.get_state("nonexistent", "fallback") == "fallback" + + def test_get_all_state_returns_copy(self, learner): + learner.register_state("a", 1) + snapshot = learner.get_all_state() + snapshot["a"] = 999 # mutate the copy + assert learner.get_state("a") == 1 # original unchanged + + def test_clear_state_empties_registry(self, learner): + learner.register_state("a", 1) + learner.register_state("b", 2) + learner.clear_state() + assert learner.get_all_state() == {} + + def test_on_state_update_callback_invoked(self, learner): + calls = [] + + def cb(k, v): + calls.append((k, v)) + + learner.on_state_update(cb) + learner.register_state("x", 42) + assert calls == [("x", 42)] + + def test_multiple_callbacks_all_invoked(self, learner): + calls_a, calls_b = [], [] + + def cb_a(k, v): + calls_a.append((k, v)) + + def cb_b(k, v): + calls_b.append((k, v)) + + learner.on_state_update(cb_a) + learner.on_state_update(cb_b) + learner.register_state("y", 7) + assert calls_a == [("y", 7)] + assert calls_b == [("y", 7)] + + def test_callback_error_does_not_break_register_state(self, learner): + def bad_callback(k, v): + raise RuntimeError("boom") + + learner.on_state_update(bad_callback) + # Should not raise + learner.register_state("z", 99) + assert learner.get_state("z") == 99 + + def test_remove_state_callback(self, learner): + calls = [] + + def cb(k, v): + calls.append((k, v)) + + learner.on_state_update(cb) + learner.remove_state_callback(cb) + learner.register_state("a", 1) + assert calls == [] + + +class TestExtractStateFromResult: + def test_dict_result_registers_all_keys(self, learner): + learner._extract_state_from_result({"loss": 0.1, "acc": 0.9}) + assert learner.get_state("loss") == 0.1 + assert learner.get_state("acc") == 0.9 + + def test_non_dict_result_does_nothing(self, learner): + learner._extract_state_from_result("some_string") + learner._extract_state_from_result(42) + learner._extract_state_from_result(None) + assert learner.get_all_state() == {} + + def test_excluded_keys_are_skipped(self, learner): + learner._extract_state_from_result( + {"loss": 0.1, "metric_value": 0.05, "should_stop": True}, + exclude_keys={"metric_value", "should_stop"}, + ) + assert learner.get_state("loss") == 0.1 + assert learner.get_state("metric_value") is None + assert learner.get_state("should_stop") is None + + +class TestBuildIterationState: + def test_builds_state_with_metric_info_from_criterion(self, learner): + learner.criterion_function = { + "metric_name": "mse", + "threshold": 0.01, + } + state = learner.build_iteration_state(iteration=3, metric_value=0.05, should_stop=False) + assert state.iteration == 3 + assert state.metric_name == "mse" + assert state.metric_threshold == 0.01 + assert state.metric_value == 0.05 + assert state.should_stop is False + + def test_builds_state_with_registered_state(self, learner): + learner.register_state("labeled_count", 200) + state = learner.build_iteration_state(iteration=0) + assert state.state["labeled_count"] == 200 + assert state.labeled_count == 200 # attribute-style access + + def test_metric_history_reflects_recorded_values(self, learner): + learner.metric_values_per_iteration = {0: 0.5, 1: 0.3} + state = learner.build_iteration_state(iteration=2, metric_value=0.1) + assert state.metric_history == [0.5, 0.3] + + def test_current_config_stored_in_state(self, learner): + cfg = LearnerConfig(training=TaskConfig(kwargs={"--lr": "0.001"})) + state = learner.build_iteration_state(iteration=0, current_config=cfg) + assert state.current_config is cfg + + def test_no_criterion_function_yields_none_metric_info(self, learner): + learner.criterion_function = {} + state = learner.build_iteration_state(iteration=0) + assert state.metric_name is None + assert state.metric_threshold is None + + +# --------------------------------------------------------------------------- +# compare_metric +# --------------------------------------------------------------------------- + + +class TestCompareMetric: + def test_less_than(self, learner): + from rose.metrics import MEAN_SQUARED_ERROR_MSE + + assert learner.compare_metric(MEAN_SQUARED_ERROR_MSE, 0.005, 0.01) is True + assert learner.compare_metric(MEAN_SQUARED_ERROR_MSE, 0.02, 0.01) is False + + def test_greater_than_custom_operator(self, learner): + assert learner.compare_metric("MY_METRIC", 5.0, 3.0, operator=">") is True + assert learner.compare_metric("MY_METRIC", 1.0, 3.0, operator=">") is False + + def test_equal_operator(self, learner): + assert learner.compare_metric("MY_METRIC", 1.0, 1.0, operator="==") is True + assert learner.compare_metric("MY_METRIC", 1.1, 1.0, operator="==") is False + + def test_custom_metric_without_operator_raises(self, learner): + with pytest.raises(ValueError, match="Operator value must be provided"): + learner.compare_metric("UNKNOWN_METRIC", 0.5, 0.1) + + def test_unknown_operator_raises(self, learner): + with pytest.raises(ValueError, match="Unknown comparison operator"): + learner.compare_metric("MY_METRIC", 0.5, 0.1, operator="!=") + + +# --------------------------------------------------------------------------- +# _stream_parallel +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestStreamParallel: + async def test_empty_run_fns_completes_immediately(self): + results = [] + async for state in _stream_parallel([]): + results.append(state) + assert results == [] + + async def test_single_learner_streams_all_states(self): + state_a = IterationState(iteration=0) + state_b = IterationState(iteration=1) + + async def run(queue: asyncio.Queue) -> None: + try: + await queue.put(("state", state_a)) + await queue.put(("state", state_b)) + finally: + await queue.put(("done", None)) + + results = [] + async for s in _stream_parallel([run]): + results.append(s) + + assert results == [state_a, state_b] + + async def test_two_learners_all_states_yielded(self): + s0 = IterationState(iteration=0, learner_id=0) + s1 = IterationState(iteration=0, learner_id=1) + + async def run_0(queue: asyncio.Queue) -> None: + try: + await queue.put(("state", s0)) + finally: + await queue.put(("done", None)) + + async def run_1(queue: asyncio.Queue) -> None: + try: + await queue.put(("state", s1)) + finally: + await queue.put(("done", None)) + + results = [] + async for s in _stream_parallel([run_0, run_1]): + results.append(s) + + assert len(results) == 2 + assert s0 in results + assert s1 in results + + async def test_done_count_terminates_loop(self): + """All N 'done' signals must arrive before _stream_parallel exits.""" + barrier = asyncio.Event() + + async def slow_run(queue: asyncio.Queue) -> None: + await barrier.wait() + try: + await queue.put(("state", IterationState(iteration=0))) + finally: + await queue.put(("done", None)) + + async def fast_run(queue: asyncio.Queue) -> None: + try: + await queue.put(("state", IterationState(iteration=0))) + finally: + await queue.put(("done", None)) + barrier.set() + + results = [] + async for s in _stream_parallel([slow_run, fast_run]): + results.append(s) + + assert len(results) == 2 + + async def test_error_is_propagated_after_all_done(self): + exc = RuntimeError("learner exploded") + + async def failing_run(queue: asyncio.Queue) -> None: + try: + await queue.put(("error", exc)) + finally: + await queue.put(("done", None)) + + with pytest.raises(RuntimeError, match="learner exploded"): + async for _ in _stream_parallel([failing_run]): + pass + + async def test_error_from_one_does_not_suppress_states_from_other(self): + good_state = IterationState(iteration=0, learner_id=0) + + async def good_run(queue: asyncio.Queue) -> None: + try: + await queue.put(("state", good_state)) + finally: + await queue.put(("done", None)) + + async def bad_run(queue: asyncio.Queue) -> None: + try: + await queue.put(("error", ValueError("oops"))) + finally: + await queue.put(("done", None)) + + results = [] + with pytest.raises(ValueError, match="oops"): + async for s in _stream_parallel([good_run, bad_run]): + results.append(s) + + # Good learner's state was streamed before exception re-raised + assert good_state in results + + async def test_only_first_error_is_raised(self): + """When two learners both fail, only the first error is re-raised.""" + + async def fail_a(queue: asyncio.Queue) -> None: + try: + await queue.put(("error", ValueError("error-A"))) + finally: + await queue.put(("done", None)) + + async def fail_b(queue: asyncio.Queue) -> None: + try: + await queue.put(("error", ValueError("error-B"))) + finally: + await queue.put(("done", None)) + + with pytest.raises(ValueError): + async for _ in _stream_parallel([fail_a, fail_b]): + pass diff --git a/tests/unit/test_learner_stop.py b/tests/unit/test_learner_stop.py index 07f1df21..9c18ef70 100644 --- a/tests/unit/test_learner_stop.py +++ b/tests/unit/test_learner_stop.py @@ -1,10 +1,11 @@ import asyncio -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest from radical.asyncflow import WorkflowEngine -from rose.al.active_learner import SequentialActiveLearner +from rose.al.active_learner import ParallelActiveLearner, SequentialActiveLearner +from rose.learner import IterationState @pytest.mark.asyncio @@ -66,3 +67,41 @@ async def run_learner(): assert count == 1 assert learner.is_stopped + + +@pytest.mark.asyncio +async def test_parallel_learner_stop_terminates_stream(): + """Test that stop() on a ParallelActiveLearner causes the async-for loop to exit.""" + mock_asyncflow = MagicMock(spec=WorkflowEngine) + learner = ParallelActiveLearner(mock_asyncflow) + + learner.simulation_function = AsyncMock(return_value="sim") + learner.training_function = AsyncMock(return_value="train") + learner.active_learn_function = AsyncMock(return_value="acl") + learner.criterion_function = AsyncMock(return_value=False) + + # Each mock sequential learner yields many states so we can verify early stop + async def mock_sequential_start(*args, **kwargs): + for i in range(50): + yield IterationState(iteration=i, should_stop=False) + + mock_seq = MagicMock() + mock_seq.start = mock_sequential_start + mock_seq.metric_values_per_iteration = {} + + count = 0 + + async def run_test(): + nonlocal count + with patch.object(learner, "_create_sequential_learner", return_value=mock_seq): + async for _state in learner.start(parallel_learners=2, max_iter=50): + count += 1 + if count == 1: + learner.stop() + + try: + await asyncio.wait_for(run_test(), timeout=5.0) + except asyncio.TimeoutError: + pytest.fail("ParallelActiveLearner did not terminate after stop() within timeout") + + assert learner.is_stopped diff --git a/tests/unit/test_parallel_learner.py b/tests/unit/test_parallel_learner.py index ca5c9b2b..3bc7c524 100644 --- a/tests/unit/test_parallel_learner.py +++ b/tests/unit/test_parallel_learner.py @@ -37,35 +37,20 @@ def configured_parallel_learner(self, parallel_learner): return parallel_learner def test_create_sequential_learner(self, configured_parallel_learner): - """Test _create_sequential_learner - method creates properly configured learner.""" + """Test _create_sequential_learner method creates properly configured learner.""" learner_id = 1 config = None - sequential = configured_parallel_learner._create_sequential_learner( - learner_id, config - ) + sequential = configured_parallel_learner._create_sequential_learner(learner_id, config) # Verify it's a SequentialActiveLearner instance assert isinstance(sequential, SequentialActiveLearner) # Verify functions are copied - assert ( - sequential.simulation_function - == configured_parallel_learner.simulation_function - ) - assert ( - sequential.training_function - == configured_parallel_learner.training_function - ) - assert ( - sequential.active_learn_function - == configured_parallel_learner.active_learn_function - ) - assert ( - sequential.criterion_function - == configured_parallel_learner.criterion_function - ) + assert sequential.simulation_function == configured_parallel_learner.simulation_function + assert sequential.training_function == configured_parallel_learner.training_function + assert sequential.active_learn_function == configured_parallel_learner.active_learn_function + assert sequential.criterion_function == configured_parallel_learner.criterion_function # Verify learner_id is set assert sequential.learner_id == learner_id @@ -102,10 +87,9 @@ async def test_start_validation_errors(self, parallel_learner): parallel_learner.active_learn_function = None parallel_learner.criterion_function = None - with pytest.raises( - ValueError, match="For single learner, use SequentialActiveLearner" - ): - await parallel_learner.start(parallel_learners=1) + with pytest.raises(ValueError, match="For single learner, use SequentialActiveLearner"): + async for _ in parallel_learner.start(parallel_learners=1): + pass # Test with missing simulation functions it should raise error about # simulation first @@ -113,7 +97,8 @@ async def test_start_validation_errors(self, parallel_learner): ValueError, match="Simulation function must be set when not using simulation pool!", ): - await parallel_learner.start(parallel_learners=2, max_iter=1) + async for _ in parallel_learner.start(parallel_learners=2, max_iter=1): + pass # Test with missing simulation functions and skip_simulation_step # it should raise an error about missing train/active_learn tasks @@ -121,9 +106,10 @@ async def test_start_validation_errors(self, parallel_learner): ValueError, match="Training and Active Learning functions must be set!", ): - await parallel_learner.start( + async for _ in parallel_learner.start( parallel_learners=2, max_iter=1, skip_simulation_step=True - ) + ): + pass # Set functions but test missing stop criteria parallel_learner.simulation_function = AsyncMock() @@ -134,23 +120,21 @@ async def test_start_validation_errors(self, parallel_learner): Exception, match="Either max_iter > 0 or criterion_function must be provided.", ): - await parallel_learner.start(parallel_learners=2, max_iter=0) + async for _ in parallel_learner.start(parallel_learners=2, max_iter=0): + pass # Test learner_configs length mismatch parallel_learner.criterion_function = AsyncMock() learner_configs = [None] # Only 1 config for 2 learners - with pytest.raises( - ValueError, match="learner_configs length must match parallel_learners" - ): - await parallel_learner.start( + with pytest.raises(ValueError, match="learner_configs length must match parallel_learners"): + async for _ in parallel_learner.start( parallel_learners=2, max_iter=1, learner_configs=learner_configs - ) + ): + pass @pytest.mark.asyncio - async def test_start_successful_parallel_execution( - self, configured_parallel_learner - ): + async def test_start_successful_parallel_execution(self, configured_parallel_learner): """Test successful parallel execution of multiple learners.""" # Create a mock that yields one state then stops (async generator) @@ -166,22 +150,20 @@ async def mock_start(*args, **kwargs): "_create_sequential_learner", return_value=mock_sequential, ): - results = await configured_parallel_learner.start( - parallel_learners=2, max_iter=1 - ) + states = [] + async for state in configured_parallel_learner.start(parallel_learners=2, max_iter=1): + states.append(state) - # Verify results - assert len(results) == 2 + # Each learner yields one state, so 2 states total + assert len(states) == 2 # Results are IterationState objects - assert all(isinstance(r, IterationState) for r in results) + assert all(isinstance(s, IterationState) for s in states) + # Each state has learner_id set to the learner index + assert {s.learner_id for s in states} == {0, 1} # Verify metric collection - assert ( - "learner-0" in configured_parallel_learner.metric_values_per_iteration - ) - assert ( - "learner-1" in configured_parallel_learner.metric_values_per_iteration - ) + assert "learner-0" in configured_parallel_learner.metric_values_per_iteration + assert "learner-1" in configured_parallel_learner.metric_values_per_iteration @pytest.mark.asyncio async def test_start_learner_failure_handling(self, configured_parallel_learner): @@ -217,11 +199,10 @@ async def fail_start(*args, **kwargs): with patch("builtins.print") as mock_print: # Should raise exception due to learner failure with pytest.raises(Exception, match="Learner failed"): - await configured_parallel_learner.start( + async for _ in configured_parallel_learner.start( parallel_learners=2, max_iter=1 - ) + ): + pass # Verify error was printed (learner 1 fails, not 0) - mock_print.assert_any_call( - "ActiveLearner-1] failed with error: Learner failed" - ) + mock_print.assert_any_call("[ActiveLearner-1] failed with error: Learner failed") diff --git a/tests/unit/test_rl_par_learner.py b/tests/unit/test_rl_par_learner.py index 881c7472..d1978748 100644 --- a/tests/unit/test_rl_par_learner.py +++ b/tests/unit/test_rl_par_learner.py @@ -52,13 +52,9 @@ def test_create_sequential_learner(self, configured_parallel_learner): sequential_learner.environment_function == configured_parallel_learner.environment_function ) + assert sequential_learner.update_function == configured_parallel_learner.update_function assert ( - sequential_learner.update_function - == configured_parallel_learner.update_function - ) - assert ( - sequential_learner.criterion_function - == configured_parallel_learner.criterion_function + sequential_learner.criterion_function == configured_parallel_learner.criterion_function ) def test_convert_to_sequential_config(self, parallel_learner): @@ -73,9 +69,7 @@ def test_convert_to_sequential_config(self, parallel_learner): mock_config.update = "upd_params" mock_config.criterion = "crit_params" - with patch( - "rose.rl.reinforcement_learner.LearnerConfig" - ) as mock_learner_config: + with patch("rose.rl.reinforcement_learner.LearnerConfig") as mock_learner_config: result = parallel_learner._convert_to_sequential_config(mock_config) mock_learner_config.assert_called_once_with( @@ -91,7 +85,8 @@ async def test_start_missing_environment_function(self, parallel_learner): parallel_learner.update_function = AsyncMock() with pytest.raises(ValueError, match="Environment and Update functions"): - await parallel_learner.start(parallel_learners=2, max_iter=1) + async for _ in parallel_learner.start(parallel_learners=2, max_iter=1): + pass @pytest.mark.asyncio async def test_start_missing_update_function(self, parallel_learner): @@ -100,47 +95,43 @@ async def test_start_missing_update_function(self, parallel_learner): parallel_learner.update_function = None with pytest.raises(ValueError, match="Environment and Update functions"): - await parallel_learner.start(parallel_learners=2, max_iter=1) + async for _ in parallel_learner.start(parallel_learners=2, max_iter=1): + pass @pytest.mark.asyncio - async def test_start_invalid_parallel_learners_count( - self, configured_parallel_learner - ): + async def test_start_invalid_parallel_learners_count(self, configured_parallel_learner): """Test that start raises exception when parallel_learners < 2.""" with pytest.raises(ValueError) as excinfo: - await configured_parallel_learner.start(parallel_learners=1, max_iter=1) + async for _ in configured_parallel_learner.start(parallel_learners=1, max_iter=1): + pass - assert "For single learner, use SequentialReinforcementLearner" in str( - excinfo.value - ) + assert "For single learner, use SequentialReinforcementLearner" in str(excinfo.value) @pytest.mark.asyncio - async def test_start_without_iterations_or_criterion( - self, configured_parallel_learner - ): - """Test that start raises exception when neither max_iter nor - criterion_function is provided.""" + async def test_start_without_iterations_or_criterion(self, configured_parallel_learner): + """Test that start raises exception when neither max_iter nor criterion_function is + provided.""" configured_parallel_learner.criterion_function = None with pytest.raises(ValueError, match="Either max_iter > 0 or criterion"): - await configured_parallel_learner.start(parallel_learners=2) + async for _ in configured_parallel_learner.start(parallel_learners=2): + pass @pytest.mark.asyncio async def test_start_mismatched_config_length(self, configured_parallel_learner): - """Test that start raises exception when learner_configs length doesn't - match parallel_learners.""" + """Test that start raises exception when learner_configs length doesn't match + parallel_learners.""" learner_configs = [LearnerConfig(), LearnerConfig()] # Length 2 with pytest.raises(ValueError) as excinfo: - await configured_parallel_learner.start( + async for _ in configured_parallel_learner.start( parallel_learners=3, # Different length max_iter=1, learner_configs=learner_configs, - ) + ): + pass - assert "learner_configs length must match parallel_learners" in str( - excinfo.value - ) + assert "learner_configs length must match parallel_learners" in str(excinfo.value) @pytest.mark.asyncio async def test_start_successful_execution(self, configured_parallel_learner): @@ -159,21 +150,20 @@ async def mock_start(*args, **kwargs): "_create_sequential_learner", return_value=mock_sequential_learner, ): - results = await configured_parallel_learner.start( - parallel_learners=2, max_iter=1 - ) + states = [] + async for state in configured_parallel_learner.start(parallel_learners=2, max_iter=1): + states.append(state) - assert len(results) == 2 + # Each learner yields one state, so 2 states total + assert len(states) == 2 # Results are IterationState objects - assert all(isinstance(r, IterationState) for r in results) + assert all(isinstance(s, IterationState) for s in states) + # Each state has learner_id set to the learner index + assert {s.learner_id for s in states} == {0, 1} # Verify metric storage - assert ( - "learner-0" in configured_parallel_learner.metric_values_per_iteration - ) - assert ( - "learner-1" in configured_parallel_learner.metric_values_per_iteration - ) + assert "learner-0" in configured_parallel_learner.metric_values_per_iteration + assert "learner-1" in configured_parallel_learner.metric_values_per_iteration @pytest.mark.asyncio async def test_start_handles_learner_exceptions(self, configured_parallel_learner): @@ -207,7 +197,8 @@ async def fail_start(*args, **kwargs): ): # The exception should propagate up and be raised with pytest.raises(Exception) as excinfo: - await configured_parallel_learner.start(parallel_learners=2, max_iter=1) + async for _ in configured_parallel_learner.start(parallel_learners=2, max_iter=1): + pass assert "Learner failed" in str(excinfo.value) @@ -229,9 +220,10 @@ async def mock_start(*args, **kwargs): "_create_sequential_learner", return_value=mock_sequential_learner, ): - await configured_parallel_learner.start( + async for _ in configured_parallel_learner.start( parallel_learners=2, max_iter=1, skip_pre_loop=True - ) + ): + pass # Verify that sequential learners were called with skip_pre_loop=True assert len(start_calls) == 2 diff --git a/tests/unit/test_rl_seq_learner.py b/tests/unit/test_rl_seq_learner.py index a2bdce6b..6eb99339 100644 --- a/tests/unit/test_rl_seq_learner.py +++ b/tests/unit/test_rl_seq_learner.py @@ -87,14 +87,12 @@ async def test_start_stops_on_criterion(self, configured_learner): @pytest.mark.asyncio async def test_start_without_iterations_or_criterion(self, sequential_learner): - """Test that start raises exception when neither max_iter nor - criterion_function is provided.""" + """Test that start raises exception when neither max_iter nor criterion_function is + provided.""" sequential_learner.environment_function = AsyncMock() sequential_learner.update_function = AsyncMock() - with pytest.raises( - ValueError, match="Either max_iter > 0 or criterion_function" - ): + with pytest.raises(ValueError, match="Either max_iter > 0 or criterion_function"): async for _ in sequential_learner.start(): pass diff --git a/tests/unit/test_sequential_learner.py b/tests/unit/test_sequential_learner.py index 2db175b7..4956cc55 100644 --- a/tests/unit/test_sequential_learner.py +++ b/tests/unit/test_sequential_learner.py @@ -28,9 +28,7 @@ def configured_learner(self, sequential_learner): """Create a fully configured SequentialActiveLearner for testing.""" sequential_learner.simulation_function = AsyncMock(return_value="sim_result") sequential_learner.training_function = AsyncMock(return_value="train_result") - sequential_learner.active_learn_function = AsyncMock( - return_value="active_result" - ) + sequential_learner.active_learn_function = AsyncMock(return_value="active_result") sequential_learner.criterion_function = AsyncMock(return_value=False) # Mock the parent class methods @@ -193,3 +191,41 @@ async def test_set_next_config(self, configured_learner): # Should be stored as pending assert configured_learner._pending_config == new_config + + @pytest.mark.asyncio + async def test_set_next_config_takes_effect_in_next_iteration(self, configured_learner): + """Test that a config set via set_next_config is consumed in the following iteration.""" + from rose.learner import LearnerConfig, TaskConfig + + new_config = LearnerConfig(training=TaskConfig(kwargs={"--lr": "0.0001"})) + + states = [] + iteration = 0 + async for state in configured_learner.start(max_iter=2, skip_pre_loop=True): + states.append(state) + if iteration == 0: + configured_learner.set_next_config(new_config) + iteration += 1 + + assert len(states) == 2 + # First iteration uses the initial config (None) + assert states[0].current_config is None + # Second iteration uses the new config set after the first yield + assert states[1].current_config is new_config + + @pytest.mark.asyncio + async def test_skip_simulation_step_does_not_register_simulation(self, configured_learner): + """Test that skip_simulation_step=True skips registering simulation tasks.""" + configured_learner._check_stop_criterion.return_value = (True, 0.01) + + async for _ in configured_learner.start( + max_iter=0, skip_pre_loop=True, skip_simulation_step=True + ): + pass + + # Every _register_task call should NOT have used simulation_function + for call in configured_learner._register_task.call_args_list: + args, _ = call + if args: + task_obj = args[0] + assert task_obj is not configured_learner.simulation_function diff --git a/tests/unit/test_uq_learner.py b/tests/unit/test_uq_learner.py index ad7352ff..d02398aa 100644 --- a/tests/unit/test_uq_learner.py +++ b/tests/unit/test_uq_learner.py @@ -14,9 +14,8 @@ async def mock_start_iterator(*args, **kwargs): """Helper to mock an async iterator.""" - # Create a mock IterationState - state = MagicMock(spec=IterationState) - state.to_dict.return_value = "learner_result" + # Yield a real IterationState so dataclasses.replace works in ParallelUQLearner + state = IterationState(iteration=0, should_stop=True) yield state @@ -40,42 +39,25 @@ def configured_parallel_learner(self, parallel_learner): parallel_learner.simulation_function = AsyncMock(return_value="sim_result") parallel_learner.training_function = AsyncMock(return_value="train_result") parallel_learner.active_learn_function = AsyncMock(return_value="active_result") - parallel_learner.prediction_function = AsyncMock( - return_value="prediction_result" - ) + parallel_learner.prediction_function = AsyncMock(return_value="prediction_result") parallel_learner.criterion_function = AsyncMock(return_value=False) parallel_learner.uncertainty_function = AsyncMock(return_value=False) return parallel_learner def test_create_sequential_learner(self, configured_parallel_learner): - """Test _create_sequential_learner - method creates properly configured learner.""" + """Test _create_sequential_learner method creates properly configured learner.""" learner_name = "learner-0" - sequential = configured_parallel_learner._create_sequential_learner( - learner_name - ) + sequential = configured_parallel_learner._create_sequential_learner(learner_name) # Verify it's a UQLearner instance assert isinstance(sequential, SeqUQLearner) # Verify functions are copied - assert ( - sequential.simulation_function - == configured_parallel_learner.simulation_function - ) - assert ( - sequential.training_function - == configured_parallel_learner.training_function - ) - assert ( - sequential.active_learn_function - == configured_parallel_learner.active_learn_function - ) - assert ( - sequential.criterion_function - == configured_parallel_learner.criterion_function - ) + assert sequential.simulation_function == configured_parallel_learner.simulation_function + assert sequential.training_function == configured_parallel_learner.training_function + assert sequential.active_learn_function == configured_parallel_learner.active_learn_function + assert sequential.criterion_function == configured_parallel_learner.criterion_function # Verify learner_name is set assert sequential.learner_name == learner_name @@ -128,12 +110,13 @@ async def test_teach_validation_errors(self, parallel_learner): Exception, match="Simulation, Training, and Active Learning functions must be set!", ): - await parallel_learner.start( + async for _ in parallel_learner.start( learner_names=["l1", "l2"], learner_configs={"l1": None, "l2": None}, model_names=["m1"], max_iter=1, - ) + ): + pass # Set functions but test missing stop criteria parallel_learner.simulation_function = AsyncMock() @@ -147,20 +130,22 @@ async def test_teach_validation_errors(self, parallel_learner): Exception, match="learner_configs length must match learner_names", ): - await parallel_learner.start( + async for _ in parallel_learner.start( learner_names=["l1", "l2"], learner_configs={"l1": None}, model_names=["m1"], max_iter=1, - ) + ): + pass with pytest.raises( Exception, match="Either max_iter or stop_criterion_function must be provided.", ): - await parallel_learner.start( + async for _ in parallel_learner.start( learner_names=["l1", "l2"], model_names=["m1"], max_iter=0 - ) + ): + pass with pytest.raises( Exception, @@ -183,15 +168,11 @@ async def test_scorer_validation_errors(self, parallel_learner): mc_preds = [[1, 2], [3, 4, 5]] y_true = [[1, 2], [3, 4, 5]] - with pytest.raises( - TypeError, match="Fail to convert mc_preds to numpy" - ): + with pytest.raises(TypeError, match="Fail to convert mc_preds to numpy"): scorer._validate_inputs(mc_preds) mc_preds = np.ones((2, 3, 4)) - with pytest.raises( - TypeError, match="Fail to convert y_true to numpy" - ): + with pytest.raises(TypeError, match="Fail to convert y_true to numpy"): scorer._validate_inputs(mc_preds, y_true) mc_preds = np.ones((2, 3)) @@ -218,15 +199,11 @@ async def test_scorer_validation_errors(self, parallel_learner): mc_preds = mc_preds = [[1, 2], [3, 4, 5]] y_true = mc_preds = [[1, 2], [3, 4, 5]] - with pytest.raises( - TypeError, match="Fail to convert mc_preds to numpy" - ): + with pytest.raises(TypeError, match="Fail to convert mc_preds to numpy"): scorer._validate_inputs(mc_preds) mc_preds = np.ones((2, 3)) - with pytest.raises( - TypeError, match="Fail to convert y_true to numpy" - ): + with pytest.raises(TypeError, match="Fail to convert y_true to numpy"): scorer._validate_inputs(mc_preds, y_true) mc_preds = np.ones((2, 3, 4)) @@ -248,9 +225,7 @@ async def test_scorer_validation_errors(self, parallel_learner): scorer._validate_inputs(mc_preds, y_true) @pytest.mark.asyncio - async def test_teach_successful_parallel_execution( - self, configured_parallel_learner - ): + async def test_teach_successful_parallel_execution(self, configured_parallel_learner): """Test successful parallel execution of multiple learners.""" # Mock the sequential learner creation and execution mock_sequential = MagicMock(spec=SeqUQLearner) @@ -263,43 +238,24 @@ async def test_teach_successful_parallel_execution( "_create_sequential_learner", return_value=mock_sequential, ): - with patch.object( - configured_parallel_learner, - "_convert_to_sequential_config", - return_value=None, + states = [] + async for state in configured_parallel_learner.start( + learner_names=["l1", "l2"], + learner_configs={"l1": None, "l2": None}, + model_names=["m1"], + max_iter=1, ): - results = await configured_parallel_learner.start( - learner_names=["l1", "l2"], - learner_configs={"l1": None, "l2": None}, - model_names=["m1"], - max_iter=1, - ) + states.append(state) - # Verify results - assert len(results) == 2 - assert all(result.to_dict() == "learner_result" for result in results) + # Each learner yields one state, so 2 states total + assert len(states) == 2 + assert all(isinstance(s, IterationState) for s in states) + # Each state has learner_id set to the learner name + assert {s.learner_id for s in states} == {"l1", "l2"} - # Verify sequential learners were called - # We can't easily check call count for a generator function mock - # but results verify it was called. - print( - "metric_values_per_iteration", - configured_parallel_learner.metric_values_per_iteration, - ) - print( - "uncertainty_values_per_iteration", - configured_parallel_learner.uncertainty_values_per_iteration, - ) - - # Verify metric collection - assert ( - "learner-l1" - in configured_parallel_learner.metric_values_per_iteration.keys() - ) - assert ( - "learner-l2" - in configured_parallel_learner.metric_values_per_iteration.keys() - ) + # Verify metric collection + assert "learner-l1" in configured_parallel_learner.metric_values_per_iteration + assert "learner-l2" in configured_parallel_learner.metric_values_per_iteration @pytest.mark.asyncio async def test_teach_learner_failure_handling(self, configured_parallel_learner): @@ -318,23 +274,19 @@ async def failing_start(*args, **kwargs): "_create_sequential_learner", return_value=mock_sequential, ): - with patch.object( - configured_parallel_learner, - "_convert_to_sequential_config", - return_value=None, - ): - # Mock print to capture error message - with patch("builtins.print") as mock_print: - # Should raise exception due to learner failure - with pytest.raises(Exception, match="Learner failed"): - await configured_parallel_learner.start( - learner_names=["l1"], - model_names=["m1"], - learner_configs={"l1": None}, - max_iter=1, - ) - - # Verify error was printed - mock_print.assert_any_call( - "[Parallel-Learner-l1] Failed with error: Learner failed" - ) + # Mock print to capture error message + with patch("builtins.print") as mock_print: + # Should raise exception due to learner failure + with pytest.raises(Exception, match="Learner failed"): + async for _ in configured_parallel_learner.start( + learner_names=["l1"], + model_names=["m1"], + learner_configs={"l1": None}, + max_iter=1, + ): + pass + + # Verify error was printed + mock_print.assert_any_call( + "[Parallel-Learner-l1] Failed with error: Learner failed" + ) diff --git a/tests/unit/tracking/test_tracker_core.py b/tests/unit/tracking/test_tracker_core.py new file mode 100644 index 00000000..55fc0958 --- /dev/null +++ b/tests/unit/tracking/test_tracker_core.py @@ -0,0 +1,82 @@ +"""Unit tests for rose.tracking: TrackerBase protocol and manifest dataclasses.""" + +from unittest.mock import MagicMock + +from rose.learner import IterationState +from rose.tracking import ( + CriterionManifest, + PipelineManifest, + TaskManifest, + TrackerBase, +) + +# --------------------------------------------------------------------------- +# TestTrackerBase +# --------------------------------------------------------------------------- + + +class TestTrackerBase: + """TrackerBase default no-op methods do not raise.""" + + def test_default_noop_on_start(self): + tracker = TrackerBase() + mock_manifest = MagicMock(spec=PipelineManifest) + tracker.on_start(mock_manifest) # should not raise + + def test_default_noop_on_iteration(self): + tracker = TrackerBase() + mock_state = MagicMock(spec=IterationState) + tracker.on_iteration(mock_state) # should not raise + + def test_default_noop_on_stop(self): + tracker = TrackerBase() + tracker.on_stop(None, "error") # should not raise + + +# --------------------------------------------------------------------------- +# TestPipelineManifest +# --------------------------------------------------------------------------- + + +class TestPipelineManifest: + """PipelineManifest and related dataclass construction.""" + + def test_manifest_defaults(self): + manifest = PipelineManifest(learner_type="X") + assert manifest.tasks == {} + assert manifest.criterion is None + assert manifest.parallel_count is None + + def test_criterion_manifest_fields(self): + cm = CriterionManifest( + func_name="check", + func_module="mymod", + as_executable=False, + metric_name="mse", + threshold=0.01, + operator="<", + ) + assert cm.func_name == "check" + assert cm.func_module == "mymod" + assert cm.as_executable is False + assert cm.metric_name == "mse" + assert cm.threshold == 0.01 + assert cm.operator == "<" + + def test_task_manifest_fields(self): + tm = TaskManifest( + func_name="sim", + func_module="mymod", + as_executable=True, + decor_kwargs={"num_gpus": 4}, + log_params={"num_gpus": 4, "kernel": "rbf"}, + ) + assert tm.func_name == "sim" + assert tm.func_module == "mymod" + assert tm.as_executable is True + assert tm.decor_kwargs == {"num_gpus": 4} + assert tm.log_params == {"num_gpus": 4, "kernel": "rbf"} + + def test_task_manifest_log_params_defaults_empty(self): + tm = TaskManifest(func_name="sim", func_module="mymod", as_executable=False) + assert tm.log_params == {} diff --git a/tests/unit/tracking/test_tracker_interface.py b/tests/unit/tracking/test_tracker_interface.py new file mode 100644 index 00000000..1d166ed8 --- /dev/null +++ b/tests/unit/tracking/test_tracker_interface.py @@ -0,0 +1,398 @@ +"""Unit tests for tracker wiring: add_tracker, _build_pipeline_manifest, +_notify_trackers_*, and full lifecycle through SequentialActiveLearner.start().""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from radical.asyncflow import WorkflowEngine + +from rose.al.active_learner import SequentialActiveLearner +from rose.learner import IterationState, TaskConfig +from rose.tracking import PipelineManifest + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class RecordingTracker: + """A simple tracker that records all lifecycle calls.""" + + def __init__(self): + self.started = False + self.manifest = None + self.iterations = [] + self.stopped = False + self.stop_reason = None + self.final_state = None + + def on_start(self, manifest): + self.started = True + self.manifest = manifest + + def on_iteration(self, state): + self.iterations.append(state) + + def on_stop(self, final_state, reason): + self.stopped = True + self.stop_reason = reason + self.final_state = final_state + + +# --------------------------------------------------------------------------- +# TestAddTracker +# --------------------------------------------------------------------------- + + +class TestAddTracker: + """Tests for Learner.add_tracker().""" + + @pytest.fixture + def mock_asyncflow(self): + return MagicMock(spec=WorkflowEngine) + + @pytest.fixture + def learner(self, mock_asyncflow): + return SequentialActiveLearner(mock_asyncflow) + + @pytest.fixture + def recording_tracker(self): + return RecordingTracker() + + def test_add_tracker_calls_on_start(self, learner, recording_tracker): + learner.add_tracker(recording_tracker) + assert recording_tracker.started is True + + def test_add_tracker_passes_manifest(self, learner, recording_tracker): + learner.add_tracker(recording_tracker) + assert isinstance(recording_tracker.manifest, PipelineManifest) + assert recording_tracker.manifest.learner_type == "SequentialActiveLearner" + + def test_add_tracker_appends_to_trackers_list(self, learner, recording_tracker): + learner.add_tracker(recording_tracker) + assert len(learner._trackers) == 1 + + def test_add_multiple_trackers(self, learner): + tracker_a = RecordingTracker() + tracker_b = RecordingTracker() + learner.add_tracker(tracker_a) + learner.add_tracker(tracker_b) + assert tracker_a.started is True + assert tracker_b.started is True + assert len(learner._trackers) == 2 + + +# --------------------------------------------------------------------------- +# TestBuildPipelineManifest +# --------------------------------------------------------------------------- + + +class TestBuildPipelineManifest: + """Tests for Learner._build_pipeline_manifest().""" + + @pytest.fixture + def mock_asyncflow(self): + return MagicMock(spec=WorkflowEngine) + + @pytest.fixture + def learner_with_tasks(self, mock_asyncflow): + learner = SequentialActiveLearner(mock_asyncflow) + + @learner.simulation_task(as_executable=False) + async def sim(*args, **kwargs): + return {} + + @learner.training_task(as_executable=False) + async def train(*args, **kwargs): + return {} + + return learner + + def test_learner_type_is_class_name(self, learner_with_tasks): + manifest = learner_with_tasks._build_pipeline_manifest() + assert manifest.learner_type == "SequentialActiveLearner" + + def test_registered_tasks_in_manifest(self, learner_with_tasks): + manifest = learner_with_tasks._build_pipeline_manifest() + assert "simulation" in manifest.tasks + assert "training" in manifest.tasks + + def test_task_manifest_func_name(self, learner_with_tasks): + manifest = learner_with_tasks._build_pipeline_manifest() + assert manifest.tasks["simulation"].func_name == "sim" + + def test_task_manifest_as_executable_false(self, learner_with_tasks): + manifest = learner_with_tasks._build_pipeline_manifest() + assert manifest.tasks["simulation"].as_executable is False + + def test_no_criterion_yields_none(self, learner_with_tasks): + manifest = learner_with_tasks._build_pipeline_manifest() + assert manifest.criterion is None + + def test_criterion_manifest_populated(self, mock_asyncflow): + learner = SequentialActiveLearner(mock_asyncflow) + + @learner.simulation_task(as_executable=False) + async def sim(*args, **kwargs): + return {} + + @learner.training_task(as_executable=False) + async def train(*args, **kwargs): + return {} + + @learner.active_learn_task(as_executable=False) + async def active_learn(*args, **kwargs): + return {} + + @learner.as_stop_criterion(metric_name="mse", threshold=0.1, operator="<") + async def check(*args, **kwargs): + return 0.05 + + manifest = learner._build_pipeline_manifest() + assert manifest.criterion is not None + assert manifest.criterion.metric_name == "mse" + assert manifest.criterion.threshold == 0.1 + assert manifest.criterion.operator == "<" + + +# --------------------------------------------------------------------------- +# TestNotifyTrackers +# --------------------------------------------------------------------------- + + +class TestNotifyTrackers: + """Tests for _notify_trackers_iteration and _notify_trackers_stop.""" + + @pytest.fixture + def mock_asyncflow(self): + return MagicMock(spec=WorkflowEngine) + + @pytest.fixture + def learner(self, mock_asyncflow): + return SequentialActiveLearner(mock_asyncflow) + + def test_notify_iteration_calls_all_trackers(self, learner): + tracker_a = RecordingTracker() + tracker_b = RecordingTracker() + learner.add_tracker(tracker_a) + learner.add_tracker(tracker_b) + + state = IterationState(iteration=0) + learner._notify_trackers_iteration(state) + + assert len(tracker_a.iterations) == 1 + assert len(tracker_b.iterations) == 1 + + def test_notify_iteration_swallows_exception(self, learner): + class BrokenTracker(RecordingTracker): + def on_iteration(self, state): + raise RuntimeError("boom") + + broken = BrokenTracker() + good = RecordingTracker() + + learner._trackers.append(broken) + learner._trackers.append(good) + + state = IterationState(iteration=0) + # Should not raise + learner._notify_trackers_iteration(state) + # Good tracker should still receive the call + assert len(good.iterations) == 1 + + def test_notify_stop_calls_all_trackers(self, learner): + tracker_a = RecordingTracker() + tracker_b = RecordingTracker() + learner.add_tracker(tracker_a) + learner.add_tracker(tracker_b) + + state = IterationState(iteration=5) + learner._notify_trackers_stop(state, "max_iter_reached") + + assert tracker_a.stopped is True + assert tracker_b.stopped is True + assert tracker_a.stop_reason == "max_iter_reached" + assert tracker_b.stop_reason == "max_iter_reached" + + def test_notify_stop_swallows_exception(self, learner): + class BrokenTracker(RecordingTracker): + def on_stop(self, final_state, reason): + raise RuntimeError("boom") + + broken = BrokenTracker() + good = RecordingTracker() + + learner._trackers.append(broken) + learner._trackers.append(good) + + state = IterationState(iteration=0) + # Should not raise + learner._notify_trackers_stop(state, "stopped") + # Good tracker should still receive the call + assert good.stopped is True + + +# --------------------------------------------------------------------------- +# TestTrackerLifecycleInLoop +# --------------------------------------------------------------------------- + + +@pytest.mark.filterwarnings("ignore::RuntimeWarning") +class TestTrackerLifecycleInLoop: + """Integration tests: tracker lifecycle through the SequentialActiveLearner loop.""" + + @pytest.fixture + def mock_asyncflow(self): + return MagicMock(spec=WorkflowEngine) + + @pytest.fixture + def learner(self, mock_asyncflow): + return SequentialActiveLearner(mock_asyncflow) + + @pytest.fixture + def configured_learner(self, learner): + learner.simulation_function = AsyncMock(return_value="sim_result") + learner.training_function = AsyncMock(return_value="train_result") + learner.active_learn_function = AsyncMock(return_value="active_result") + learner.criterion_function = AsyncMock(return_value=False) + learner._get_iteration_task_config = MagicMock(return_value=MagicMock(spec=TaskConfig)) + learner._register_task = AsyncMock(return_value="task_result") + learner._check_stop_criterion = MagicMock(return_value=(False, None)) + return learner + + def test_on_start_called_once_at_add_tracker(self, configured_learner): + tracker = RecordingTracker() + configured_learner.add_tracker(tracker) + assert tracker.started is True + + @pytest.mark.asyncio + async def test_on_iteration_called_per_iteration(self, configured_learner): + tracker = RecordingTracker() + configured_learner.add_tracker(tracker) + + async for _ in configured_learner.start(max_iter=3, skip_pre_loop=True): + pass + + assert len(tracker.iterations) == 3 + + @pytest.mark.asyncio + async def test_on_iteration_receives_correct_iteration_number(self, configured_learner): + tracker = RecordingTracker() + configured_learner.add_tracker(tracker) + + async for _ in configured_learner.start(max_iter=3, skip_pre_loop=True): + pass + + for i in range(3): + assert tracker.iterations[i].iteration == i + + @pytest.mark.asyncio + async def test_on_stop_called_after_max_iter(self, configured_learner): + tracker = RecordingTracker() + configured_learner.add_tracker(tracker) + + async for _ in configured_learner.start(max_iter=2, skip_pre_loop=True): + pass + + assert tracker.stopped is True + assert tracker.stop_reason == "max_iter_reached" + + @pytest.mark.asyncio + async def test_on_stop_called_when_criterion_met(self, configured_learner): + configured_learner._check_stop_criterion.side_effect = [(True, 0.005)] + + tracker = RecordingTracker() + configured_learner.add_tracker(tracker) + + async for _ in configured_learner.start(max_iter=0, skip_pre_loop=True): + pass + + assert tracker.stopped is True + assert tracker.stop_reason == "criterion_met" + + @pytest.mark.asyncio + async def test_on_stop_called_on_early_break(self, configured_learner): + tracker = RecordingTracker() + configured_learner.add_tracker(tracker) + + gen = configured_learner.start(max_iter=10, skip_pre_loop=True) + async for _ in gen: + break + await gen.aclose() + + assert tracker.stopped is True + + @pytest.mark.asyncio + async def test_on_stop_final_state_is_last_yielded(self, configured_learner): + tracker = RecordingTracker() + configured_learner.add_tracker(tracker) + + async for _ in configured_learner.start(max_iter=3, skip_pre_loop=True): + pass + + assert tracker.final_state is not None + assert tracker.final_state.iteration == 2 + + @pytest.mark.asyncio + async def test_on_stop_always_called_even_on_exception(self, configured_learner): + call_count = 0 + + async def raise_after_first(*args, **kwargs): + nonlocal call_count + call_count += 1 + # Raise on 4th call (after sim, train, AL, criterion in iteration 0 + # then next sim/train prep fails) + if call_count > 3: + raise RuntimeError("simulated failure") + return "task_result" + + configured_learner._register_task = raise_after_first + + tracker = RecordingTracker() + configured_learner.add_tracker(tracker) + + try: + async for _ in configured_learner.start(max_iter=5, skip_pre_loop=True): + pass + except RuntimeError: + pass + + assert tracker.stopped is True + + @pytest.mark.asyncio + async def test_multiple_trackers_all_get_lifecycle_calls(self, configured_learner): + tracker_a = RecordingTracker() + tracker_b = RecordingTracker() + configured_learner.add_tracker(tracker_a) + configured_learner.add_tracker(tracker_b) + + async for _ in configured_learner.start(max_iter=2, skip_pre_loop=True): + pass + + for tracker in (tracker_a, tracker_b): + assert tracker.started is True + assert len(tracker.iterations) == 2 + assert tracker.stopped is True + + @pytest.mark.asyncio + async def test_tracker_exception_does_not_break_learner(self, configured_learner): + class NoisyTracker: + def on_start(self, manifest): + pass + + def on_iteration(self, state): + raise RuntimeError("tracker error") + + def on_stop(self, final_state, reason): + raise RuntimeError("tracker stop error") + + def on_state_update(self, key, value): + pass + + configured_learner.add_tracker(NoisyTracker()) + + states = [] + async for state in configured_learner.start(max_iter=2, skip_pre_loop=True): + states.append(state) + + assert len(states) == 2 diff --git a/tox.ini b/tox.ini index 6fd5416e..a9b97835 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py39,py310,py311,py312,py313 +envlist = py310,py311,py312,py313 isolated_build = true # Unit test env @@ -8,25 +8,23 @@ extras = dev commands = pytest tests/unit {posargs} # Integration test -[testenv:{py39,py310,py311,py312,py313}-all] +[testenv:{py310,py311,py312,py313}-all] extras = dev setenv = RADICAL_VERBOSE=DEBUG -commands_pre = - radical-stack commands = pytest tests/integration {posargs} # Linting [testenv:lint] extras = lint -commands = +commands = ruff check rose tests ruff format --check rose tests # Formatting [testenv:format] extras = lint -commands = +commands = ruff format rose tests ruff check --fix rose tests diff --git a/examples/tutorials/00-active-learning.ipynb b/tutorials/00-active-learning.ipynb similarity index 98% rename from examples/tutorials/00-active-learning.ipynb rename to tutorials/00-active-learning.ipynb index 67204608..499a96f6 100644 --- a/examples/tutorials/00-active-learning.ipynb +++ b/tutorials/00-active-learning.ipynb @@ -28,7 +28,7 @@ "Today’s challenge: Scientists must wire together training, simulation, and learning tasks — this is manual, fragile, not scalable, or performance efficient.\n", "\n", "ROSE advantage:\n", - "* Automates, scales, portabilizes, and asynchronously executes workflow learners (training, active learning, UQ, RL) on a large scale on HPC efficently." + "* Automates, scales, portabilizes, and asynchronously executes workflow learners (training, active learning, UQ, RL) on a large scale on HPC efficiently." ] }, { @@ -92,38 +92,11 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "82ae0aae-91a7-4be9-8ff2-9ffab7b64a88", "metadata": {}, "outputs": [], - "source": [ - "import os\n", - "import sys\n", - "import time\n", - "import asyncio\n", - "import logging\n", - "\n", - "from typing import Dict, List, Any\n", - "from dataclasses import dataclass\n", - "\n", - "# learner and task level imports\n", - "import numpy as np\n", - "from sklearn.gaussian_process import GaussianProcessRegressor\n", - "from sklearn.gaussian_process.kernels import RBF, WhiteKernel\n", - "from sklearn.metrics import mean_squared_error\n", - "\n", - "# ROSE top layer imports\n", - "from rose.metrics import MEAN_SQUARED_ERROR_MSE\n", - "from rose.al.active_learner import Learner, SequentialActiveLearner, ParallelActiveLearner\n", - "\n", - "# ROSE Bottom layers imports\n", - "from radical.asyncflow import WorkflowEngine\n", - "from radical.asyncflow import ConcurrentExecutionBackend\n", - "from concurrent.futures import ProcessPoolExecutor\n", - "from radical.asyncflow.logging import init_default_logger\n", - "\n", - "logger = logging.getLogger(__name__)" - ] + "source": "import os\nimport sys\nimport time\nimport asyncio\nimport logging\n\nfrom typing import Dict, List, Any\nfrom dataclasses import dataclass\n\n# learner and task level imports\nimport numpy as np\nfrom sklearn.gaussian_process import GaussianProcessRegressor\nfrom sklearn.gaussian_process.kernels import RBF, WhiteKernel\nfrom sklearn.metrics import mean_squared_error\n\n# ROSE top layer imports\nfrom rose.metrics import MEAN_SQUARED_ERROR_MSE\nfrom rose.al.active_learner import Learner, SequentialActiveLearner, ParallelActiveLearner\n\n# ROSE Bottom layers imports\nfrom radical.asyncflow import WorkflowEngine\nfrom rhapsody.backends import ConcurrentExecutionBackend\nfrom concurrent.futures import ProcessPoolExecutor\nfrom radical.asyncflow.logging import init_default_logger\n\nlogger = logging.getLogger(__name__)" }, { "cell_type": "code", diff --git a/examples/tutorials/01-reinforcement-learning.ipynb b/tutorials/01-reinforcement-learning.ipynb similarity index 97% rename from examples/tutorials/01-reinforcement-learning.ipynb rename to tutorials/01-reinforcement-learning.ipynb index 8deda997..ba9b92da 100644 --- a/examples/tutorials/01-reinforcement-learning.ipynb +++ b/tutorials/01-reinforcement-learning.ipynb @@ -47,36 +47,11 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "d1c26fa2", "metadata": {}, "outputs": [], - "source": [ - "import asyncio\n", - "import logging\n", - "\n", - "from typing import List, Tuple\n", - "from dataclasses import dataclass\n", - "\n", - "# Task imports\n", - "import numpy as np\n", - "import torch\n", - "import torch.nn as nn\n", - "import torch.optim as optim\n", - "import gymnasium as gym\n", - "\n", - "# ROSE top layer imports\n", - "from rose.metrics import GREATER_THAN_THRESHOLD\n", - "from rose.rl.reinforcement_learner import SequentialReinforcementLearner\n", - "\n", - "# ROSE Bottom layers imports\n", - "from radical.asyncflow import WorkflowEngine\n", - "from radical.asyncflow import ConcurrentExecutionBackend\n", - "from concurrent.futures import ProcessPoolExecutor\n", - "from radical.asyncflow.logging import init_default_logger\n", - "\n", - "logger = logging.getLogger(__name__)" - ] + "source": "import asyncio\nimport logging\n\nfrom typing import List, Tuple\nfrom dataclasses import dataclass\n\n# Task imports\nimport numpy as np\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nimport gymnasium as gym\n\n# ROSE top layer imports\nfrom rose.metrics import GREATER_THAN_THRESHOLD\nfrom rose.rl.reinforcement_learner import SequentialReinforcementLearner\n\n# ROSE Bottom layers imports\nfrom radical.asyncflow import WorkflowEngine\nfrom rhapsody.backends import ConcurrentExecutionBackend\nfrom concurrent.futures import ProcessPoolExecutor\nfrom radical.asyncflow.logging import init_default_logger\n\nlogger = logging.getLogger(__name__)" }, { "cell_type": "code", diff --git a/examples/active_learn/highly_parallel_surrogates_on_hpc.ipynb b/tutorials/03-highly-parallel-surrogates.ipynb similarity index 98% rename from examples/active_learn/highly_parallel_surrogates_on_hpc.ipynb rename to tutorials/03-highly-parallel-surrogates.ipynb index ae6b9838..c5178546 100644 --- a/examples/active_learn/highly_parallel_surrogates_on_hpc.ipynb +++ b/tutorials/03-highly-parallel-surrogates.ipynb @@ -28,7 +28,7 @@ "Today’s challenge: Scientists must wire together training, simulation, and learning tasks — this is manual, fragile, not scalable, or performance efficient.\n", "\n", "ROSE advantage:\n", - "* Automates, scales, portabilizes, and asynchronously executes workflow learners (training, active learning, UQ, RL) on a large scale on HPC efficently." + "* Automates, scales, portabilizes, and asynchronously executes workflow learners (training, active learning, UQ, RL) on a large scale on HPC efficiently." ] }, { @@ -92,46 +92,11 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "82ae0aae-91a7-4be9-8ff2-9ffab7b64a88", "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "kj/filesystem-disk-unix.c++:1734: warning: PWD environment variable doesn't match current directory; pwd = /home/aymen\n" - ] - } - ], - "source": [ - "import os\n", - "import sys\n", - "import time\n", - "import asyncio\n", - "import logging\n", - "\n", - "from typing import Dict, List, Any\n", - "from dataclasses import dataclass\n", - "\n", - "# learner and task level imports\n", - "import numpy as np\n", - "from sklearn.gaussian_process import GaussianProcessRegressor\n", - "from sklearn.gaussian_process.kernels import RBF, WhiteKernel\n", - "from sklearn.metrics import mean_squared_error\n", - "\n", - "# ROSE top layer imports\n", - "from rose.metrics import MEAN_SQUARED_ERROR_MSE\n", - "from rose.al.active_learner import Learner, SequentialActiveLearner, ParallelActiveLearner\n", - "\n", - "# ROSE Bottom layers imports\n", - "from radical.asyncflow import WorkflowEngine\n", - "from radical.asyncflow import ConcurrentExecutionBackend\n", - "from concurrent.futures import ProcessPoolExecutor\n", - "from radical.asyncflow.logging import init_default_logger\n", - "\n", - "logger = logging.getLogger(__name__)" - ] + "outputs": [], + "source": "import os\nimport sys\nimport time\nimport asyncio\nimport logging\n\nfrom typing import Dict, List, Any\nfrom dataclasses import dataclass\n\n# learner and task level imports\nimport numpy as np\nfrom sklearn.gaussian_process import GaussianProcessRegressor\nfrom sklearn.gaussian_process.kernels import RBF, WhiteKernel\nfrom sklearn.metrics import mean_squared_error\n\n# ROSE top layer imports\nfrom rose.metrics import MEAN_SQUARED_ERROR_MSE\nfrom rose.al.active_learner import Learner, SequentialActiveLearner, ParallelActiveLearner\n\n# ROSE Bottom layers imports\nfrom radical.asyncflow import WorkflowEngine\nfrom rhapsody.backends import ConcurrentExecutionBackend\nfrom concurrent.futures import ProcessPoolExecutor\nfrom radical.asyncflow.logging import init_default_logger\n\nlogger = logging.getLogger(__name__)" }, { "cell_type": "code", diff --git a/examples/active_learn/algorithm_selector/algorithm-selector-tutorial.ipynb b/tutorials/04-al-algorithm-selector.ipynb similarity index 99% rename from examples/active_learn/algorithm_selector/algorithm-selector-tutorial.ipynb rename to tutorials/04-al-algorithm-selector.ipynb index 24028196..b90538cc 100644 --- a/examples/active_learn/algorithm_selector/algorithm-selector-tutorial.ipynb +++ b/tutorials/04-al-algorithm-selector.ipynb @@ -14,16 +14,7 @@ "id": "c653f197-c2da-4222-86b9-716b75ae03b9", "metadata": {}, "outputs": [], - "source": [ - "import os\n", - "import sys\n", - "\n", - "from rose.al.selector import AlgorithmSelector\n", - "from rose.metrics import MEAN_SQUARED_ERROR_MSE\n", - "\n", - "from radical.asyncflow import WorkflowEngine\n", - "from radical.asyncflow import RadicalExecutionBackend" - ] + "source": "import os\nimport sys\n\nfrom rose.al.selector import AlgorithmSelector\nfrom rose.metrics import MEAN_SQUARED_ERROR_MSE\n\nfrom radical.asyncflow import WorkflowEngine\nfrom rhapsody.backends import RadicalExecutionBackend" }, { "cell_type": "markdown", diff --git a/examples/tutorials/README.md b/tutorials/README.md similarity index 73% rename from examples/tutorials/README.md rename to tutorials/README.md index b7fc5cf2..1eac830d 100644 --- a/examples/tutorials/README.md +++ b/tutorials/README.md @@ -6,8 +6,10 @@ This folder contains tutorial environments for **ROSE**. ## Available Tutorials -- **00-active-learning** – Active learning / Gaussian Process tutorials +- **00-active-learning** – Active learning / Gaussian Process tutorials - **01-reinforcement-learning** – Reinforcement learning / PyTorch + Gym tutorials +- **03-highly-parallel-surrogates** – Highly parallel surrogate training with ensemble GP models +- **04-al-algorithm-selector** – Running multiple AL algorithms in parallel and selecting the best Each tutorial has its own optional dependencies. The common dependencies are required for all tutorials. @@ -31,6 +33,12 @@ pip install -e ".[00al]" # Tutorial 01rl (01-reinforcement-learning) pip install -e ".[01rl]" + +# Tutorial 03hp (03-highly-parallel-surrogates) +pip install -e ".[03hp]" + +# Tutorial 04also (04-al-algorithm-selector) +pip install -e ".[04also]" ``` ## Usage diff --git a/examples/tutorials/pyproject.toml b/tutorials/pyproject.toml similarity index 83% rename from examples/tutorials/pyproject.toml rename to tutorials/pyproject.toml index f0a31a2f..963ad762 100644 --- a/examples/tutorials/pyproject.toml +++ b/tutorials/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "rose-tutorials" version = "0.1.0" description = "ROSE tutorials environment" -requires-python = ">=3.9" +requires-python = ">=3.10" # -------------------- # Common dependencies (for all tutorials) @@ -29,3 +29,13 @@ dependencies = [ "torch", "gymnasium", ] + +03hp = [ + "scikit-learn", + "scipy", +] + +04als = [ + "pandas", + "matplotlib", +] From d02ded7f37eaaa988c3ef20ba51efbbb5f0c95aa Mon Sep 17 00:00:00 2001 From: AymenFJA Date: Mon, 16 Mar 2026 16:50:17 +0100 Subject: [PATCH 12/30] This commit: 1-Fix pre-commits 2-Sets the RAAS branch up to data with main new changes --- examples/service/example_rose_plugin.py | 84 +++--- examples/service/run_service.py | 46 ++-- examples/service/verify_service.py | 31 +-- rose/service/__init__.py | 2 +- rose/service/api/cli.py | 38 ++- rose/service/api/rest.py | 329 ++++++++++-------------- rose/service/client.py | 47 ++-- rose/service/manager.py | 209 ++++++++------- rose/service/models.py | 31 ++- tests/unit/test_rose_plugin.py | 170 ++++++------ 10 files changed, 490 insertions(+), 497 deletions(-) diff --git a/examples/service/example_rose_plugin.py b/examples/service/example_rose_plugin.py index 5d381dcf..fa796acd 100755 --- a/examples/service/example_rose_plugin.py +++ b/examples/service/example_rose_plugin.py @@ -15,36 +15,36 @@ python example_rose_plugin.py [--workflow FILE] [--job-id ID] """ +import argparse +import logging import os import sys import time -import logging -import argparse from pathlib import Path logging.basicConfig( level=logging.DEBUG, - format='%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s', - datefmt='%H:%M:%S' + format="%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s", + datefmt="%H:%M:%S", ) -for name in ['httpx', 'httpcore', 'urllib3']: +for name in ["httpx", "httpcore", "urllib3"]: logging.getLogger(name).setLevel(logging.DEBUG) -log = logging.getLogger('rose.example') +log = logging.getLogger("rose.example") def notification_cb(topic: str, data: dict): """Handle workflow notifications.""" - log.info(f'[NOTIFY] {topic}: {data}') + log.info(f"[NOTIFY] {topic}: {data}") def main(): - parser = argparse.ArgumentParser(description='Test ROSE Edge Plugin') - parser.add_argument('--workflow', '-w', default='debug_workflow.yaml') - parser.add_argument('--job-id', '-j', default='local_job_0') - parser.add_argument('--bridge-url', '-b', - default=os.environ.get('RADICAL_BRIDGE_URL', - 'https://localhost:8443')) + parser = argparse.ArgumentParser(description="Test ROSE Edge Plugin") + parser.add_argument("--workflow", "-w", default="debug_workflow.yaml") + parser.add_argument("--job-id", "-j", default="local_job_0") + parser.add_argument( + "--bridge-url", "-b", default=os.environ.get("RADICAL_BRIDGE_URL", "https://localhost:8443") + ) args = parser.parse_args() # Resolve workflow path @@ -52,13 +52,13 @@ def main(): if not workflow.exists(): workflow = Path(__file__).parent / args.workflow if not workflow.exists(): - log.error(f'Workflow not found: {args.workflow}') + log.error(f"Workflow not found: {args.workflow}") sys.exit(1) - log.info(f'Bridge: {args.bridge_url}') - log.info(f'Workflow: {workflow}') - log.info(f'Job ID: {args.job_id}') - log.info('-' * 60) + log.info(f"Bridge: {args.bridge_url}") + log.info(f"Workflow: {workflow}") + log.info(f"Job ID: {args.job_id}") + log.info("-" * 60) from radical.edge import BridgeClient @@ -69,69 +69,69 @@ def main(): bc = BridgeClient(url=args.bridge_url) edges = bc.list_edges() except Exception as e: - log.error(f'Cannot connect to bridge: {e}') - log.error('Make sure bridge and edge service are running.') + log.error(f"Cannot connect to bridge: {e}") + log.error("Make sure bridge and edge service are running.") sys.exit(1) if not edges: - log.error('No edges connected to bridge') + log.error("No edges connected to bridge") sys.exit(1) edge_id = edges[0] - log.info(f'Using edge: {edge_id}') + log.info(f"Using edge: {edge_id}") try: ec = bc.get_edge_client(edge_id) - rose = ec.get_plugin('rose', job_id=args.job_id) + rose = ec.get_plugin("rose", job_id=args.job_id) except Exception as e: - log.error(f'Cannot get ROSE plugin: {e}') - log.error('Make sure ROSE plugin is loaded on edge service.') + log.error(f"Cannot get ROSE plugin: {e}") + log.error("Make sure ROSE plugin is loaded on edge service.") sys.exit(1) - log.info(f'Session: {rose.sid}') + log.info(f"Session: {rose.sid}") rose.on_workflow_state(notification_cb) try: # Submit workflow - log.info(f'Submitting {workflow}...') + log.info(f"Submitting {workflow}...") result = rose.submit_workflow(str(workflow.absolute())) - wf_id = result['wf_id'] - log.info(f'Submitted: {wf_id}') + wf_id = result["wf_id"] + log.info(f"Submitted: {wf_id}") # Monitor status - log.info('Monitoring (Ctrl+C to cancel)...') - terminal = {'COMPLETED', 'FAILED', 'CANCELED'} + log.info("Monitoring (Ctrl+C to cancel)...") + terminal = {"COMPLETED", "FAILED", "CANCELED"} last_state = None - for i in range(120): + for _i in range(120): time.sleep(2) try: status = rose.get_workflow_status(wf_id) - state = status.get('state', 'UNKNOWN') + state = status.get("state", "UNKNOWN") if state != last_state: - log.info(f'State: {state}') + log.info(f"State: {state}") last_state = state if state in terminal: - if state == 'FAILED': - log.error(f'Error: {status.get("error")}') + if state == "FAILED": + log.error(f"Error: {status.get('error')}") break except Exception as e: - log.warning(f'Status error: {e}') + log.warning(f"Status error: {e}") # Final state - log.info('-' * 60) + log.info("-" * 60) for wid, info in rose.list_workflows().items(): - log.info(f'{wid}: {info.get("state")}') + log.info(f"{wid}: {info.get('state')}") except KeyboardInterrupt: - log.warning('Interrupted') + log.warning("Interrupted") finally: rose.off_workflow_state(notification_cb) rose.close() bc.close() - log.info('Done.') + log.info("Done.") -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/examples/service/run_service.py b/examples/service/run_service.py index 4bf98e43..39117567 100644 --- a/examples/service/run_service.py +++ b/examples/service/run_service.py @@ -1,34 +1,38 @@ import asyncio -import os -import time import json -import shutil import logging +import os +import shutil from pathlib import Path -from rose.service.manager import ServiceManager + from rose.service.client import ServiceClient +from rose.service.manager import ServiceManager # Configure logging to see what's happening -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) logger = logging.getLogger("run_service") # Job ID for this service instance JOB_ID = "rose_service_run" SERVICE_ROOT = Path.home() / ".rose" / "services" / JOB_ID + def cleanup(): if SERVICE_ROOT.exists(): logger.info(f"Cleaning up previous service root at {SERVICE_ROOT}") shutil.rmtree(SERVICE_ROOT) + async def run_workflow(): print(f"--- Starting ROSE Service for Job {JOB_ID} ---") cleanup() - + # 1. Start Service Manager in a background task manager = ServiceManager(JOB_ID) service_task = asyncio.create_task(manager.run()) - + # Wait for service initialization await asyncio.sleep(1) @@ -45,7 +49,7 @@ async def run_workflow(): print(f"Submitting workflow: {wf_path}") req_id = client.submit_workflow(wf_path) print(f"Submitted. Request ID: {req_id}") - + # 4. Wait for workflow to be picked up and assigned a wf_id wf_id = None print("Waiting for service to assign Workflow ID...") @@ -56,7 +60,7 @@ async def run_workflow(): wf_id = list(registry.keys())[0] print(f"Assigned Workflow ID: {wf_id}") break - + if not wf_id: print("Error: Workflow was not picked up by the service.") await manager.shutdown() @@ -70,12 +74,12 @@ async def run_workflow(): if not status: print("Error: Workflow status lost.") break - - current_state = status.get('state') + + current_state = status.get("state") if current_state != last_state: print(f"Status Change: {current_state}") last_state = current_state - + if current_state in ["COMPLETED", "FAILED", "CANCELED"]: print(f"Workflow reached terminal state: {current_state}") if current_state == "FAILED": @@ -83,17 +87,20 @@ async def run_workflow(): break # Optional: Print iteration progress if available in stats - stats = status.get('stats', {}) - if 'iteration' in stats: - print(f" Iteration: {stats['iteration']} | Metric: {stats.get('metric_value', 'N/A')}", end='\r') - + stats = status.get("stats", {}) + if "iteration" in stats: + print( + f" Iteration: {stats['iteration']} | Metric: {stats.get('metric_value', 'N/A')}", + end="\r", + ) + await asyncio.sleep(2) - + # 6. Final Report status = client.get_workflow_status(wf_id) print("\n--- Final Workflow Status ---") print(json.dumps(status, indent=2)) - + # 7. Graceful Shutdown print("Shutting down service...") await manager.shutdown() @@ -102,9 +109,10 @@ async def run_workflow(): await asyncio.wait_for(service_task, timeout=5) except (asyncio.TimeoutError, asyncio.CancelledError): pass - + print("--- Service Run Finished ---") + if __name__ == "__main__": try: asyncio.run(run_workflow()) diff --git a/examples/service/verify_service.py b/examples/service/verify_service.py index 472f63d2..3f3c79b0 100644 --- a/examples/service/verify_service.py +++ b/examples/service/verify_service.py @@ -1,40 +1,40 @@ import asyncio -import os -import time -import json import shutil from pathlib import Path -from rose.service.manager import ServiceManager + from rose.service.client import ServiceClient +from rose.service.manager import ServiceManager # Mock Job ID JOB_ID = "test_job_123" SERVICE_ROOT = Path.home() / ".rose" / "services" / JOB_ID + def cleanup(): if SERVICE_ROOT.exists(): shutil.rmtree(SERVICE_ROOT) + async def run_verification(): print(f"--- Starting Verification for Job {JOB_ID} ---") cleanup() - + # 1. Start Service in Background Task manager = ServiceManager(JOB_ID) service_task = asyncio.create_task(manager.run()) - + # Allow service to init await asyncio.sleep(2) - + # 2. Initialize Client client = ServiceClient(JOB_ID) - + # 3. Submit Workflow wf_path = "service_real.yaml" print(f"Submitting {wf_path}...") req_id = client.submit_workflow(wf_path) print(f"Submitted. Request ID: {req_id}") - + # 4. Poll for Status until Running wf_id = None for _ in range(10): @@ -42,11 +42,11 @@ async def run_verification(): registry = client.list_workflows() if registry: wf_id = list(registry.keys())[0] - state = registry[wf_id]['state'] + state = registry[wf_id]["state"] print(f"Workflow {wf_id} State: {state}") if state in ["RUNNING", "COMPLETED"]: break - + if not wf_id: print("Failed to get workflow ID") await manager.shutdown() @@ -55,19 +55,19 @@ async def run_verification(): # 5. Cancel Workflow (if still running) print(f"Canceling {wf_id}...") client.cancel_workflow(wf_id) - + # 6. Check for Canceled State for _ in range(5): await asyncio.sleep(1) status = client.get_workflow_status(wf_id) print(f"Workflow {wf_id} State: {status['state']}") - if status['state'] == "CANCELED": + if status["state"] == "CANCELED": print("SUCCESS: Workflow Canceled") break - if status['state'] == "COMPLETED": + if status["state"] == "COMPLETED": print("Workflow finished before cancel (acceptable for short test)") break - + # Shutdown await manager.shutdown() try: @@ -76,5 +76,6 @@ async def run_verification(): pass print("--- Verification Finished ---") + if __name__ == "__main__": asyncio.run(run_verification()) diff --git a/rose/service/__init__.py b/rose/service/__init__.py index ee1f1f9d..e3fddf12 100644 --- a/rose/service/__init__.py +++ b/rose/service/__init__.py @@ -1,3 +1,3 @@ -from .models import WorkflowState, Workflow +from .models import Workflow, WorkflowState __all__ = ["WorkflowState", "Workflow"] diff --git a/rose/service/api/cli.py b/rose/service/api/cli.py index 1f5c6b82..24c21f1e 100644 --- a/rose/service/api/cli.py +++ b/rose/service/api/cli.py @@ -1,12 +1,13 @@ import argparse import asyncio -import os -import sys import json import logging +import os +import sys -from rose.service.manager import ServiceManager from rose.service.client import ServiceClient +from rose.service.manager import ServiceManager + def get_job_id(): """Get SLURM_JOB_ID from env or argument.""" @@ -14,43 +15,46 @@ def get_job_id(): # For 'launch', we usually rely on env var if running inside job. return os.environ.get("SLURM_JOB_ID", "local_job_0") + def cmd_launch(args): """Start the Service Manager.""" # Configure logging for the service logging.basicConfig( level=logging.INFO, - format='%(asctime)s [%(levelname)s] %(name)s: %(message)s', - datefmt='%H:%M:%S' + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%H:%M:%S", ) - + job_id = args.job_id or get_job_id() print(f"Launching ROSE Service for Job ID: {job_id}") - + manager = ServiceManager(job_id) # The loop in manager.run() handles signals if radical.asyncflow does, # and the finally block ensures manager.shutdown() is called. asyncio.run(manager.run()) + def cmd_submit(args): """Submit a workflow.""" job_id = args.job_id or get_job_id() client = ServiceClient(job_id) - + try: req_id = client.submit_workflow(args.workflow_file) wf_id = ServiceClient.get_wf_id(req_id) - print(f"Submitted workflow request.") + print("Submitted workflow request.") print(f"Request ID: {req_id}") print(f"Workflow ID: {wf_id}") except Exception as e: print(f"Error submitting workflow: {e}") sys.exit(1) + def cmd_cancel(args): """Cancel a workflow.""" job_id = args.job_id or get_job_id() client = ServiceClient(job_id) - + try: req_id = client.cancel_workflow(args.wf_id) print(f"Sent cancellation request for {args.wf_id}. Request ID: {req_id}") @@ -58,11 +62,12 @@ def cmd_cancel(args): print(f"Error cancelling workflow: {e}") sys.exit(1) + def cmd_status(args): """Get status.""" job_id = args.job_id or get_job_id() client = ServiceClient(job_id) - + try: if args.wf_id: status = client.get_workflow_status(args.wf_id) @@ -80,6 +85,7 @@ def cmd_status(args): print(f"Error getting status: {e}") sys.exit(1) + def cmd_shutdown(args): """Shutdown the service.""" job_id = args.job_id or get_job_id() @@ -91,6 +97,7 @@ def cmd_shutdown(args): print(f"Error sending shutdown request: {e}") sys.exit(1) + def main(): parser = argparse.ArgumentParser(description="ROSE Service CLI") subparsers = parser.add_subparsers(dest="command", required=True) @@ -100,7 +107,9 @@ def main(): parent_parser.add_argument("--job-id", help="SLURM Job ID (default: $SLURM_JOB_ID)") # Launch - p_launch = subparsers.add_parser("launch", parents=[parent_parser], help="Start the Service Manager daemon") + p_launch = subparsers.add_parser( + "launch", parents=[parent_parser], help="Start the Service Manager daemon" + ) p_launch.set_defaults(func=cmd_launch) # Submit @@ -119,11 +128,14 @@ def main(): p_status.set_defaults(func=cmd_status) # Shutdown - p_shutdown = subparsers.add_parser("shutdown", parents=[parent_parser], help="Shutdown the service") + p_shutdown = subparsers.add_parser( + "shutdown", parents=[parent_parser], help="Shutdown the service" + ) p_shutdown.set_defaults(func=cmd_shutdown) args = parser.parse_args() args.func(args) + if __name__ == "__main__": main() diff --git a/rose/service/api/rest.py b/rose/service/api/rest.py index fdd9ff18..935f7e14 100644 --- a/rose/service/api/rest.py +++ b/rose/service/api/rest.py @@ -55,34 +55,35 @@ - ``radical.edge.plugin_base.Plugin`` - Base plugin class """ -__author__ = 'RADICAL Development Team' -__email__ = 'radical@radical-project.org' -__copyright__ = 'Copyright 2024, RADICAL@Rutgers' -__license__ = 'MIT' +__author__ = "RADICAL Development Team" +__email__ = "radical@radical-project.org" +__copyright__ = "Copyright 2024, RADICAL@Rutgers" +__license__ = "MIT" import asyncio -import uuid -import time import logging -from typing import Dict, Any, Optional +import time +import uuid from fastapi import FastAPI, HTTPException, Request -from starlette.responses import JSONResponse - +from radical.asyncflow import LocalExecutionBackend, WorkflowEngine +from radical.edge.client import PluginClient +from radical.edge.plugin_base import Plugin from radical.edge.plugin_session_base import PluginSession -from radical.edge.plugin_base import Plugin -from radical.edge.client import PluginClient -from radical.edge.ui_schema import UIConfig, UIForm, UIField, \ - UIFormSubmit, UIMonitor, \ - UINotifications - -from radical.asyncflow import WorkflowEngine, LocalExecutionBackend +from radical.edge.ui_schema import ( + UIConfig, + UIField, + UIForm, + UIFormSubmit, + UIMonitor, + UINotifications, +) +from starlette.responses import JSONResponse -from rose.al.active_learner import SequentialActiveLearner, ParallelActiveLearner -from rose.service.models import Workflow, WorkflowState +from rose.al.active_learner import ParallelActiveLearner from rose.service.manager import WorkflowLoader - +from rose.service.models import Workflow, WorkflowState log = logging.getLogger("radical.edge") @@ -90,31 +91,28 @@ # ------------------------------------------------------------------------------ # class RoseSession(PluginSession): - """ - ROSE session (service-side). + """ROSE session (service-side). - Directly manages workflow execution using AsyncFlow, eliminating the need - for a separate ServiceManager process. + Directly manages workflow execution using AsyncFlow, eliminating the need for a separate + ServiceManager process. """ # -------------------------------------------------------------------------- # def __init__(self, sid: str): - """ - Initialize a RoseSession. + """Initialize a RoseSession. Args: sid (str): Unique session identifier assigned by the plugin. """ super().__init__(sid) - self._workflows: Dict[str, Workflow] = {} - self._learner_tasks: Dict[str, asyncio.Task] = {} - self._engine: Optional[WorkflowEngine] = None + self._workflows: dict[str, Workflow] = {} + self._learner_tasks: dict[str, asyncio.Task] = {} + self._engine: WorkflowEngine | None = None self._engine_lock = asyncio.Lock() self._initialized = False - # -------------------------------------------------------------------------- # async def _ensure_engine(self): @@ -126,18 +124,16 @@ async def _ensure_engine(self): if self._engine is not None: return - log.info(f'[{self.sid}] Initializing workflow engine') + log.info(f"[{self.sid}] Initializing workflow engine") backend = LocalExecutionBackend() self._engine = await WorkflowEngine.create(backend) self._initialized = True - log.info(f'[{self.sid}] Workflow engine ready') - + log.info(f"[{self.sid}] Workflow engine ready") # -------------------------------------------------------------------------- # async def submit_workflow(self, workflow_file: str) -> dict: - """ - Submit a workflow YAML file for execution. + """Submit a workflow YAML file for execution. Args: workflow_file (str): Absolute or relative path to the workflow YAML. @@ -149,31 +145,25 @@ async def submit_workflow(self, workflow_file: str) -> dict: await self._ensure_engine() # Generate workflow ID - wf_id = f'wf.{uuid.uuid4().hex[:8]}' + wf_id = f"wf.{uuid.uuid4().hex[:8]}" # Create workflow record - wf = Workflow( - wf_id=wf_id, - state=WorkflowState.SUBMITTED, - workflow_file=workflow_file - ) + wf = Workflow(wf_id=wf_id, state=WorkflowState.SUBMITTED, workflow_file=workflow_file) self._workflows[wf_id] = wf # Notify submission if self._notify: - self._notify('workflow_state', { - 'wf_id': wf_id, - 'state': 'SUBMITTED', - 'workflow_file': workflow_file - }) + self._notify( + "workflow_state", + {"wf_id": wf_id, "state": "SUBMITTED", "workflow_file": workflow_file}, + ) # Start workflow execution in background task = asyncio.create_task(self._run_workflow(wf)) self._learner_tasks[wf_id] = task - log.info(f'[{self.sid}] Submitted workflow {wf_id}: {workflow_file}') - return {'wf_id': wf_id} - + log.info(f"[{self.sid}] Submitted workflow {wf_id}: {workflow_file}") + return {"wf_id": wf_id} # -------------------------------------------------------------------------- # @@ -188,9 +178,7 @@ async def _run_workflow(self, wf: Workflow): # Load workflow definition wf_def = WorkflowLoader.load_yaml(wf.workflow_file) - learner, initial_config = WorkflowLoader.create_learner( - wf_id, wf_def, self._engine - ) + learner, initial_config = WorkflowLoader.create_learner(wf_id, wf_def, self._engine) wf.learner_instance = learner # Run @@ -198,78 +186,68 @@ async def _run_workflow(self, wf: Workflow): wf.start_time = time.time() self._notify_state(wf) - config = wf_def.get('config', {}) - learner_cfg = wf_def.get('learner', {}) - max_iter = config.get('max_iterations', - learner_cfg.get('max_iterations', 10)) + config = wf_def.get("config", {}) + learner_cfg = wf_def.get("learner", {}) + max_iter = config.get("max_iterations", learner_cfg.get("max_iterations", 10)) - log.info(f'[{self.sid}] Running workflow {wf_id} ' - f'(max_iterations={max_iter})') + log.info(f"[{self.sid}] Running workflow {wf_id} (max_iterations={max_iter})") if isinstance(learner, ParallelActiveLearner): - parallel = config.get('parallel_learners', - learner_cfg.get('parallel_learners', 2)) + parallel = config.get("parallel_learners", learner_cfg.get("parallel_learners", 2)) configs = [initial_config] * parallel if initial_config else None results = await learner.start( - parallel_learners=parallel, - max_iter=max_iter, - learner_configs=configs + parallel_learners=parallel, max_iter=max_iter, learner_configs=configs ) - wf.stats = {'parallel_results': [str(r) for r in results]} + wf.stats = {"parallel_results": [str(r) for r in results]} else: # Sequential learner - async iterator - async for state in learner.start( - max_iter=max_iter, - initial_config=initial_config - ): + async for state in learner.start(max_iter=max_iter, initial_config=initial_config): wf.stats = state.to_dict() - log.info(f'[{self.sid}] {wf_id} iteration {state.iteration} ' - f'(metric={state.metric_value})') + log.info( + f"[{self.sid}] {wf_id} iteration {state.iteration} " + f"(metric={state.metric_value})" + ) self._notify_state(wf) # Completed wf.state = WorkflowState.COMPLETED wf.end_time = time.time() - log.info(f'[{self.sid}] Workflow {wf_id} completed') + log.info(f"[{self.sid}] Workflow {wf_id} completed") except asyncio.CancelledError: wf.state = WorkflowState.CANCELED wf.end_time = time.time() - log.info(f'[{self.sid}] Workflow {wf_id} canceled') + log.info(f"[{self.sid}] Workflow {wf_id} canceled") except Exception as e: wf.state = WorkflowState.FAILED wf.error = str(e) wf.end_time = time.time() - log.error(f'[{self.sid}] Workflow {wf_id} failed: {e}') + log.error(f"[{self.sid}] Workflow {wf_id} failed: {e}") import traceback + traceback.print_exc() finally: self._notify_state(wf) self._learner_tasks.pop(wf_id, None) - # -------------------------------------------------------------------------- # def _notify_state(self, wf: Workflow): """Send workflow state notification.""" if self._notify: - self._notify('workflow_state', { - 'wf_id': wf.wf_id, - 'state': wf.state.value, - 'stats': wf.stats, - 'error': wf.error - }) - + self._notify( + "workflow_state", + {"wf_id": wf.wf_id, "state": wf.state.value, "stats": wf.stats, "error": wf.error}, + ) # -------------------------------------------------------------------------- # async def get_workflow_status(self, wf_id: str) -> dict: - """ - Return the current status of a workflow. + """Return the current status of a workflow. Args: wf_id (str): The workflow ID. @@ -284,17 +262,14 @@ async def get_workflow_status(self, wf_id: str) -> dict: wf = self._workflows.get(wf_id) if not wf: - raise HTTPException(status_code=404, - detail=f"workflow '{wf_id}' not found") + raise HTTPException(status_code=404, detail=f"workflow '{wf_id}' not found") return wf.to_dict() - # -------------------------------------------------------------------------- # async def list_workflows(self) -> dict: - """ - List all workflows in this session. + """List all workflows in this session. Returns: dict: Mapping ``wf_id → state dict``. @@ -303,12 +278,10 @@ async def list_workflows(self) -> dict: return {wf_id: wf.to_dict() for wf_id, wf in self._workflows.items()} - # -------------------------------------------------------------------------- # async def cancel_workflow(self, wf_id: str) -> dict: - """ - Cancel a running workflow. + """Cancel a running workflow. Args: wf_id (str): The workflow ID to cancel. @@ -320,13 +293,14 @@ async def cancel_workflow(self, wf_id: str) -> dict: wf = self._workflows.get(wf_id) if not wf: - raise HTTPException(status_code=404, - detail=f"workflow '{wf_id}' not found") + raise HTTPException(status_code=404, detail=f"workflow '{wf_id}' not found") - if wf.state not in (WorkflowState.RUNNING, WorkflowState.INITIALIZING, - WorkflowState.SUBMITTED): - raise HTTPException(status_code=400, - detail=f"workflow '{wf_id}' not running") + if wf.state not in ( + WorkflowState.RUNNING, + WorkflowState.INITIALIZING, + WorkflowState.SUBMITTED, + ): + raise HTTPException(status_code=400, detail=f"workflow '{wf_id}' not running") # Stop the learner if wf.learner_instance: @@ -337,24 +311,18 @@ async def cancel_workflow(self, wf_id: str) -> dict: if task and not task.done(): task.cancel() - log.info(f'[{self.sid}] Canceling workflow {wf_id}') + log.info(f"[{self.sid}] Canceling workflow {wf_id}") if self._notify: - self._notify('workflow_state', { - 'wf_id': wf_id, - 'state': 'CANCELING' - }) - - return {'wf_id': wf_id} + self._notify("workflow_state", {"wf_id": wf_id, "state": "CANCELING"}) + return {"wf_id": wf_id} # -------------------------------------------------------------------------- # async def close(self) -> dict: - """ - Close this session, stopping all workflows and cleaning up. - """ - log.info(f'[{self.sid}] Closing session') + """Close this session, stopping all workflows and cleaning up.""" + log.info(f"[{self.sid}] Closing session") # Stop all learners for wf in self._workflows.values(): @@ -367,8 +335,7 @@ async def close(self) -> dict: task.cancel() if self._learner_tasks: - await asyncio.gather(*self._learner_tasks.values(), - return_exceptions=True) + await asyncio.gather(*self._learner_tasks.values(), return_exceptions=True) self._learner_tasks.clear() # Shutdown engine @@ -382,8 +349,7 @@ async def close(self) -> dict: # ------------------------------------------------------------------------------ # class RoseClient(PluginClient): - """ - Application-side client for the ROSE plugin. + """Application-side client for the ROSE plugin. Provides a thin sync wrapper over the HTTP endpoints exposed by ``PluginRose``. @@ -392,32 +358,27 @@ class RoseClient(PluginClient): # -------------------------------------------------------------------------- # def on_workflow_state(self, callback): - """ - Register a callback for workflow state change notifications. + """Register a callback for workflow state change notifications. Args: callback: A callable(topic, data) to invoke on state changes. """ self.register_notification_callback(callback) - # -------------------------------------------------------------------------- # def off_workflow_state(self, callback): - """ - Unregister a workflow state change callback. + """Unregister a workflow state change callback. Args: callback: The callback to unregister. """ self.unregister_notification_callback(callback) - # -------------------------------------------------------------------------- # def submit_workflow(self, workflow_file: str) -> dict: - """ - Submit a workflow YAML file. + """Submit a workflow YAML file. Args: workflow_file (str): Path to the workflow YAML file. @@ -426,20 +387,19 @@ def submit_workflow(self, workflow_file: str) -> dict: dict: ``{wf_id}``. """ if not self.sid: - raise RuntimeError('No active session') + raise RuntimeError("No active session") - resp = self._http.post(self._url(f'submit/{self.sid}'), - json={'workflow_file': workflow_file}) + resp = self._http.post( + self._url(f"submit/{self.sid}"), json={"workflow_file": workflow_file} + ) resp.raise_for_status() return resp.json() - # -------------------------------------------------------------------------- # def get_workflow_status(self, wf_id: str) -> dict: - """ - Get the current status of a workflow. + """Get the current status of a workflow. Args: wf_id (str): Workflow ID. @@ -448,37 +408,33 @@ def get_workflow_status(self, wf_id: str) -> dict: dict: Workflow state dictionary. """ if not self.sid: - raise RuntimeError('No active session') + raise RuntimeError("No active session") - resp = self._http.get(self._url(f'status/{self.sid}/{wf_id}')) + resp = self._http.get(self._url(f"status/{self.sid}/{wf_id}")) resp.raise_for_status() return resp.json() - # -------------------------------------------------------------------------- # def list_workflows(self) -> dict: - """ - List all workflows in the session. + """List all workflows in the session. Returns: dict: Registry mapping ``wf_id → state dict``. """ if not self.sid: - raise RuntimeError('No active session') + raise RuntimeError("No active session") - resp = self._http.get(self._url(f'workflows/{self.sid}')) + resp = self._http.get(self._url(f"workflows/{self.sid}")) resp.raise_for_status() return resp.json() - # -------------------------------------------------------------------------- # def cancel_workflow(self, wf_id: str) -> dict: - """ - Cancel a running workflow. + """Cancel a running workflow. Args: wf_id (str): Workflow ID to cancel. @@ -487,9 +443,9 @@ def cancel_workflow(self, wf_id: str) -> dict: dict: ``{wf_id}``. """ if not self.sid: - raise RuntimeError('No active session') + raise RuntimeError("No active session") - resp = self._http.post(self._url(f'cancel/{self.sid}/{wf_id}')) + resp = self._http.post(self._url(f"cancel/{self.sid}/{wf_id}")) resp.raise_for_status() return resp.json() @@ -498,8 +454,7 @@ def cancel_workflow(self, wf_id: str) -> dict: # ------------------------------------------------------------------------------ # class PluginRose(Plugin): - """ - ROSE plugin for RADICAL-Edge. + """ROSE plugin for RADICAL-Edge. Exposes workflow management via REST endpoints, with embedded execution (no separate ServiceManager process required). @@ -513,112 +468,98 @@ class PluginRose(Plugin): - POST /rose/cancel/{sid}/{wf_id} """ - plugin_name = 'rose' + plugin_name = "rose" session_class = RoseSession - client_class = RoseClient - version = '0.2.0' - session_ttl = 0 # No timeout - workflows can run for hours/days + client_class = RoseClient + version = "0.2.0" + session_ttl = 0 # No timeout - workflows can run for hours/days ui_config = UIConfig( - icon='🌹', - title='ROSE Active Learning', - description='Submit and monitor Active Learning workflows', + icon="🌹", + title="ROSE Active Learning", + description="Submit and monitor Active Learning workflows", refresh_button=True, forms=[ UIForm( - id='submit', - title='Submit Workflow', - layout='single', + id="submit", + title="Submit Workflow", + layout="single", fields=[ UIField( - name='workflow_file', - type='text', - label='Workflow File', - placeholder='/path/to/workflow.yaml', - required=True + name="workflow_file", + type="text", + label="Workflow File", + placeholder="/path/to/workflow.yaml", + required=True, ) ], - submit=UIFormSubmit( - label='Submit', - style='success', - endpoint='submit/{sid}' - ) + submit=UIFormSubmit(label="Submit", style="success", endpoint="submit/{sid}"), ) ], monitors=[ UIMonitor( - id='workflows', - title='Workflows', - type='task_list', - css_class='workflow-list', - empty_text='No workflows submitted yet', - auto_load='workflows/{sid}' + id="workflows", + title="Workflows", + type="task_list", + css_class="workflow-list", + empty_text="No workflows submitted yet", + auto_load="workflows/{sid}", ) ], notifications=UINotifications( - topic='workflow_state', - id_field='wf_id', - state_field='state' - ) + topic="workflow_state", id_field="wf_id", state_field="state" + ), ) - # -------------------------------------------------------------------------- # - def __init__(self, app: FastAPI, instance_name: str = 'rose'): - """ - Initialize the ROSE plugin, registering all routes. - """ + def __init__(self, app: FastAPI, instance_name: str = "rose"): + """Initialize the ROSE plugin, registering all routes.""" super().__init__(app, instance_name) - self.add_route_post('submit/{sid}', self.submit_workflow) - self.add_route_get ('status/{sid}/{wf_id}', self.get_workflow_status) - self.add_route_get ('workflows/{sid}', self.list_workflows) - self.add_route_post('cancel/{sid}/{wf_id}', self.cancel_workflow) + self.add_route_post("submit/{sid}", self.submit_workflow) + self.add_route_get("status/{sid}/{wf_id}", self.get_workflow_status) + self.add_route_get("workflows/{sid}", self.list_workflows) + self.add_route_post("cancel/{sid}/{wf_id}", self.cancel_workflow) self._log_routes() - # -------------------------------------------------------------------------- # async def submit_workflow(self, request: Request) -> JSONResponse: """Submit a workflow YAML file.""" - sid = request.path_params['sid'] + sid = request.path_params["sid"] data = await request.json() - return await self._forward(sid, RoseSession.submit_workflow, - workflow_file=data.get('workflow_file')) - + return await self._forward( + sid, RoseSession.submit_workflow, workflow_file=data.get("workflow_file") + ) # -------------------------------------------------------------------------- # async def get_workflow_status(self, request: Request) -> JSONResponse: """Return the status of a specific workflow.""" - sid = request.path_params['sid'] - wf_id = request.path_params['wf_id'] - - return await self._forward(sid, RoseSession.get_workflow_status, - wf_id=wf_id) + sid = request.path_params["sid"] + wf_id = request.path_params["wf_id"] + return await self._forward(sid, RoseSession.get_workflow_status, wf_id=wf_id) # -------------------------------------------------------------------------- # async def list_workflows(self, request: Request) -> JSONResponse: """List all workflows in the session.""" - sid = request.path_params['sid'] + sid = request.path_params["sid"] return await self._forward(sid, RoseSession.list_workflows) - # -------------------------------------------------------------------------- # async def cancel_workflow(self, request: Request) -> JSONResponse: """Cancel a running workflow.""" - sid = request.path_params['sid'] - wf_id = request.path_params['wf_id'] + sid = request.path_params["sid"] + wf_id = request.path_params["wf_id"] - return await self._forward(sid, RoseSession.cancel_workflow, - wf_id=wf_id) + return await self._forward(sid, RoseSession.cancel_workflow, wf_id=wf_id) # ------------------------------------------------------------------------------ diff --git a/rose/service/client.py b/rose/service/client.py index 57b2017a..a168824f 100644 --- a/rose/service/client.py +++ b/rose/service/client.py @@ -1,15 +1,16 @@ import json -import uuid -import time import logging +import time +import uuid from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any logger = logging.getLogger(__name__) + class ServiceClient: """Client for interacting with the ROSE Service via File-based IPC. - + Attributes: job_id (str): The SLURM job ID where the service is running. service_root (Path): Root directory for service IPC (~/.rose/services/). @@ -27,38 +28,38 @@ def __init__(self, job_id: str): self.registry_file = self.service_root / "registry.json" if not self.service_root.exists(): - # It's possible the service hasn't started creating dirs yet, - # or the job ID is wrong. We don't raise immediately to allow + # It's possible the service hasn't started creating dirs yet, + # or the job ID is wrong. We don't raise immediately to allow # retry logic in scripts, but warn if needed. logger.warning(f"Service root {self.service_root} does not exist yet.") - def _write_request(self, action: str, payload: Dict[str, Any]) -> str: + def _write_request(self, action: str, payload: dict[str, Any]) -> str: """Write a request file to the requests directory.""" req_id = str(uuid.uuid4()) request_data = { "id": req_id, "action": action, "timestamp": time.time(), - "payload": payload + "payload": payload, } - - # Ensure requests dir exists (client might start before service creates it? + + # Ensure requests dir exists (client might start before service creates it? # Better to assume service creates it, but safe to check) if not self.requests_dir.exists(): - raise RuntimeError(f"Service requests directory not found: {self.requests_dir}") + raise RuntimeError(f"Service requests directory not found: {self.requests_dir}") req_file = self.requests_dir / f"{action}_{req_id}.json" with open(req_file, "w") as f: json.dump(request_data, f, indent=2) - + return req_id def submit_workflow(self, workflow_file: str) -> str: """Submit a workflow file to the service. - + Args: workflow_file (str): Path to the workflow YAML file. - + Returns: str: Request ID (not yet the wf_id, which depends on service processing). """ @@ -67,7 +68,7 @@ def submit_workflow(self, workflow_file: str) -> str: def cancel_workflow(self, wf_id: str) -> str: """Request cancellation of a workflow. - + Args: wf_id (str): The workflow ID to cancel. """ @@ -77,29 +78,29 @@ def shutdown(self) -> str: """Request graceful shutdown of the service.""" return self._write_request("shutdown", {}) - def get_workflow_status(self, wf_id: str) -> Optional[Dict[str, Any]]: + def get_workflow_status(self, wf_id: str) -> dict[str, Any] | None: """Get the current status of a workflow from the registry. - + Args: wf_id (str): Workflow ID. - + Returns: dict: Workflow state dict or None if not found. """ registry = self._read_registry() return registry.get(wf_id) - - def list_workflows(self) -> Dict[str, Any]: + + def list_workflows(self) -> dict[str, Any]: """List all workflows in the registry.""" return self._read_registry() - def _read_registry(self) -> Dict[str, Any]: + def _read_registry(self) -> dict[str, Any]: """Read and parse the registry file.""" if not self.registry_file.exists(): return {} - + try: - with open(self.registry_file, "r") as f: + with open(self.registry_file) as f: return json.load(f) except (json.JSONDecodeError, FileNotFoundError): # Race condition on read or empty file diff --git a/rose/service/manager.py b/rose/service/manager.py index a3cf6022..1d7f4e86 100644 --- a/rose/service/manager.py +++ b/rose/service/manager.py @@ -1,46 +1,51 @@ import asyncio -import json -import os import importlib +import json import logging +import os +from collections.abc import Callable from pathlib import Path -from typing import Any, Dict, Optional, List, Callable +from typing import Any -from radical.asyncflow import WorkflowEngine, LocalExecutionBackend +from radical.asyncflow import LocalExecutionBackend, WorkflowEngine -from rose.al.active_learner import SequentialActiveLearner, ParallelActiveLearner +from rose.al.active_learner import ParallelActiveLearner, SequentialActiveLearner from rose.learner import LearnerConfig, TaskConfig -from .models import Workflow, WorkflowState + from .client import ServiceClient +from .models import Workflow, WorkflowState logger = logging.getLogger(__name__) + class WorkflowLoader: """Helper to load a Learner from a YAML definition.""" - + @staticmethod - def load_yaml(path: str) -> Dict[str, Any]: - """Load YAML file (mocking yaml load with json for now or basic parsing if yaml lib not avail? - User environment might have PyYAML. Assuming yaml is available or using json for simplicity if needed. - The user request says 'workflow.yaml', so we should try to support YAML. - If PyYAML is not installed, we might fallback or error. - Standard python doesn't have yaml. + def load_yaml(path: str) -> dict[str, Any]: + """Load YAML file (mocking yaml load with json for now or basic parsing if yaml lib not + avail? + + User environment might have PyYAML. Assuming yaml is available or using json for simplicity + if needed. The user request says 'workflow.yaml', so we should try to support YAML. If + PyYAML is not installed, we might fallback or error. Standard python doesn't have yaml. """ # For this implementation, I will assume PyYAML is available as it's common in this stack, # or I will implement a very simple parser if restricted. - # Given "ROSE" context, PyYAML is likely a dependency. - # But to be safe and depend only on stdlib as requested ("standard libraries only" was for IPC, but let's stick to it), - # I will check if yaml module exists, otherwise parsing simple key-value or use JSON. - # However, the user explicitly said "workflow.yaml". + # Given "ROSE" context, PyYAML is likely a dependency. + # But to be safe and depend only on stdlib as requested ("standard libraries only" was for + # IPC, but let's stick to it), check if yaml module exists, otherwise parse as JSON. + # However, the user explicitly said "workflow.yaml". # I will try to import yaml. try: import yaml - with open(path, "r") as f: + + with open(path) as f: return yaml.safe_load(f) except ImportError: # Fallback: simpler parsing or expect JSON content in .yaml (not ideal) logger.warning("PyYAML not found, trying JSON parsing for workflow file") - with open(path, "r") as f: + with open(path) as f: return json.load(f) @staticmethod @@ -51,15 +56,16 @@ def _import_function(path_str: str) -> Callable: module = importlib.import_module(module_name) return getattr(module, func_name) except (ValueError, ImportError, AttributeError) as e: - raise ImportError(f"Could not import function '{path_str}': {e}") + raise ImportError(f"Could not import function '{path_str}': {e}") from e @staticmethod def _create_script_task_factory(script_path: str) -> Callable: """Create a task function that returns the script path + arguments. - + Args: script_path: The base command or script path. """ + async def task_func(*args, **kwargs): # Extract string arguments to append to the command. # Skip Task objects (dependencies). @@ -67,24 +73,26 @@ async def task_func(*args, **kwargs): for arg in args: if isinstance(arg, str): cmd_parts.append(arg) - + for k, v in kwargs.items(): if isinstance(v, bool): - if v: cmd_parts.append(f"--{k}") + if v: + cmd_parts.append(f"--{k}") else: - cmd_parts.append(f"--{k} {v}") + cmd_parts.append(f"--{k} {v}") return " ".join(cmd_parts) + return task_func @classmethod - def create_learner(cls, wf_id: str, workflow_def: Dict[str, Any], asyncflow: WorkflowEngine): + def create_learner(cls, wf_id: str, workflow_def: dict[str, Any], asyncflow: WorkflowEngine): """Create and configure a Learner based on the YAML definition.""" - + learner_def = workflow_def.get("learner", {}) l_type = learner_def.get("type", "SequentialActiveLearner") l_path = learner_def.get("path") - + # 1. Instantiate Learner Class if l_path: # Load custom learner class @@ -93,7 +101,7 @@ def create_learner(cls, wf_id: str, workflow_def: Dict[str, Any], asyncflow: Wor module = importlib.import_module(module_name) learner_cls = getattr(module, class_name) except (ValueError, ImportError, AttributeError) as e: - raise ImportError(f"Could not import learner class '{l_path}': {e}") + raise ImportError(f"Could not import learner class '{l_path}': {e}") from e elif l_type == "SequentialActiveLearner": learner_cls = SequentialActiveLearner else: @@ -101,14 +109,14 @@ def create_learner(cls, wf_id: str, workflow_def: Dict[str, Any], asyncflow: Wor raise ValueError(f"Unknown learner type '{l_type}' and no path provided.") learner = learner_cls(asyncflow) - learner.learner_id = wf_id # Using wf_id (str) might need adaptation if learner_id expects int in some places? - # rose/active_learner.py: learner_id (Optional[int]). + learner.learner_id = wf_id + # rose/active_learner.py: learner_id (Optional[int]). # I should probably hash the wf_id or just set it if it accepts Any? # The type hint says Optional[int]. Let's ignore type hint for a moment or hash it. - learner.learner_id = hash(wf_id) + learner.learner_id = hash(wf_id) components = workflow_def.get("components", {}) - + # 2. Register Components # Expecting structure: # components: @@ -116,18 +124,18 @@ def create_learner(cls, wf_id: str, workflow_def: Dict[str, Any], asyncflow: Wor # type: function | script # path: ... # config: ... - + for name in ["simulation", "training", "active_learn", "criterion"]: comp_def = components.get(name) if not comp_def: continue - - ctype = comp_def.get("type", "script") # Default to script? + + ctype = comp_def.get("type", "script") # Default to script? cpath = comp_def.get("path") - + task_func = None as_executable = True - + if ctype == "function": task_func = cls._import_function(cpath) as_executable = False @@ -151,7 +159,9 @@ def create_learner(cls, wf_id: str, workflow_def: Dict[str, Any], asyncflow: Wor logger.info(f"Registering criterion task for workflow {wf_id}") threshold = comp_def.get("threshold", 0.0) metric = comp_def.get("metric", "CUSTOM") - learner.as_stop_criterion(metric_name=metric, threshold=threshold, as_executable=as_executable)(task_func) + learner.as_stop_criterion( + metric_name=metric, threshold=threshold, as_executable=as_executable + )(task_func) # 3. Build initial LearnerConfig from component defs # This ensures that args/kwargs specified in YAML are used @@ -161,23 +171,23 @@ def create_learner(cls, wf_id: str, workflow_def: Dict[str, Any], asyncflow: Wor if comp_def and "config" in comp_def: c_config = comp_def["config"] t_config = TaskConfig( - args=tuple(c_config.get("args", ())), - kwargs=c_config.get("kwargs", {}) + args=tuple(c_config.get("args", ())), kwargs=c_config.get("kwargs", {}) ) setattr(l_config, name, t_config) return learner, l_config + class ServiceManager: def __init__(self, job_id: str): self.job_id = job_id self.service_root = Path.home() / ".rose" / "services" / str(job_id) self.requests_dir = self.service_root / "requests" self.registry_file = self.service_root / "registry.json" - - self.workflows: Dict[str, Workflow] = {} - self.engine: Optional[WorkflowEngine] = None - self._learner_tasks: List[asyncio.Task] = [] + + self.workflows: dict[str, Workflow] = {} + self.engine: WorkflowEngine | None = None + self._learner_tasks: list[asyncio.Task] = [] self._shutdown = False async def initialize(self): @@ -195,15 +205,15 @@ async def _process_requests(self): # Sort by mtime to process in order? req_files = sorted(self.requests_dir.glob("*.json"), key=os.path.getmtime) - + for req_file in req_files: try: - with open(req_file, "r") as f: + with open(req_file) as f: req = json.load(f) - + action = req.get("action") payload = req.get("payload", {}) - + if action == "submit": await self._handle_submit(req.get("id"), payload) elif action == "cancel": @@ -211,20 +221,20 @@ async def _process_requests(self): elif action == "shutdown": logger.info("Shutdown request received via IPC") self._shutdown = True - + # Remove request file after processing req_file.unlink() - + except Exception as e: logger.error(f"Error processing request {req_file}: {e}") # Move to failed_requests? Or just delete? # For now, delete to avoid loop try: req_file.unlink() - except: + except Exception: pass - async def _handle_submit(self, req_id: str, payload: Dict[str, Any]): + async def _handle_submit(self, req_id: str, payload: dict[str, Any]): wf_file = payload.get("workflow_file") if not wf_file: logger.error("No workflow file in submit payload") @@ -233,7 +243,7 @@ async def _handle_submit(self, req_id: str, payload: Dict[str, Any]): # Use request ID as part of wf_id or generate new? # User goal: "assigned a unique workflow identifier (wf_id)" wf_id = ServiceClient.get_wf_id(req_id) - + wf = Workflow(wf_id=wf_id, state=WorkflowState.INITIALIZING, workflow_file=wf_file) self.workflows[wf_id] = wf self._update_registry() @@ -242,78 +252,96 @@ async def _handle_submit(self, req_id: str, payload: Dict[str, Any]): wf_def = WorkflowLoader.load_yaml(wf_file) learner, initial_l_config = WorkflowLoader.create_learner(wf_id, wf_def, self.engine) wf.learner_instance = learner - + # Merge with top-level config if needed (e.g. if we want to override via top-level) # For now, initial_l_config from components is primary. - + # Start the learner loop as a background task - task = asyncio.create_task(self._run_learner(wf, wf_def.get("config", {}), initial_l_config)) + task = asyncio.create_task( + self._run_learner(wf, wf_def.get("config", {}), initial_l_config) + ) self._learner_tasks.append(task) - + except Exception as e: logger.error(f"Failed to submit workflow {wf_id}: {e}") wf.state = WorkflowState.FAILED wf.error = str(e) self._update_registry() - async def _handle_cancel(self, payload: Dict[str, Any]): + async def _handle_cancel(self, payload: dict[str, Any]): wf_id = payload.get("wf_id") wf = self.workflows.get(wf_id) - if wf and wf.state in [WorkflowState.RUNNING, WorkflowState.INITIALIZING, WorkflowState.SUBMITTED]: + if wf and wf.state in [ + WorkflowState.RUNNING, + WorkflowState.INITIALIZING, + WorkflowState.SUBMITTED, + ]: logger.info(f"Canceling workflow {wf_id}") if wf.learner_instance: - wf.learner_instance.stop() # Cooperative cancel + wf.learner_instance.stop() # Cooperative cancel wf.state = WorkflowState.CANCELED self._update_registry() - async def _run_learner(self, wf: Workflow, workflow_def: Dict[str, Any], initial_l_config: Optional[LearnerConfig] = None): + async def _run_learner( + self, + wf: Workflow, + workflow_def: dict[str, Any], + initial_l_config: LearnerConfig | None = None, + ): """Driver loop for a single workflow.""" wf.state = WorkflowState.RUNNING wf.start_time = asyncio.get_event_loop().time() logger.info(f"Starting workflow {wf.wf_id} ({wf.workflow_file})") self._update_registry() - + try: learner_cfg = workflow_def.get("learner", {}) max_iter = learner_cfg.get("max_iterations", workflow_def.get("max_iterations", 0)) - + # Identify learner type and call appropriately if isinstance(wf.learner_instance, ParallelActiveLearner): - parallel_learners = learner_cfg.get("parallel_learners", workflow_def.get("parallel_learners", 2)) - - # ParallelActiveLearner.start doesn't take initial_config, + parallel_learners = learner_cfg.get( + "parallel_learners", workflow_def.get("parallel_learners", 2) + ) + + # ParallelActiveLearner.start doesn't take initial_config, # but we can map it to learner_configs l_configs = None if initial_l_config: l_configs = [initial_l_config] * parallel_learners - - results = await wf.learner_instance.start( + + async for state in wf.learner_instance.start( parallel_learners=parallel_learners, max_iter=max_iter, - learner_configs=l_configs + learner_configs=l_configs, + ): + wf.stats = ( + state.to_dict() if hasattr(state, "to_dict") else {"result": str(state)} + ) + learner_id = getattr(state, "learner_id", "?") + iteration = getattr(state, "iteration", "?") + metric = getattr(state, "metric_value", "?") + logger.info( + f"Workflow {wf.wf_id} - Learner {learner_id}," + f" iteration {iteration} completed (metric: {metric})" + ) + self._update_registry() + logger.info( + f"Workflow {wf.wf_id} - Parallel execution of" + f" {parallel_learners} learners finished" ) - logger.info(f"Workflow {wf.wf_id} - Parallel execution of {parallel_learners} learners finished") - - # Update stats once at the end for parallel (since it's not yielding) - final_results = [] - for res in results: - if hasattr(res, "to_dict"): - final_results.append(res.to_dict()) - else: - final_results.append(str(res)) - - wf.stats = {"parallel_results": final_results} - self._update_registry() else: # SequentialActiveLearner or other async iterator async for state in wf.learner_instance.start( - max_iter=max_iter, - initial_config=initial_l_config + max_iter=max_iter, initial_config=initial_l_config ): wf.stats = state.to_dict() - logger.info(f"Workflow {wf.wf_id} - Iteration {state.iteration} completed (metric: {state.metric_value})") + logger.info( + f"Workflow {wf.wf_id} - Iteration {state.iteration}" + f" completed (metric: {state.metric_value})" + ) self._update_registry() - + wf.state = WorkflowState.COMPLETED logger.info(f"Workflow {wf.wf_id} completed successfully") except Exception as e: @@ -321,6 +349,7 @@ async def _run_learner(self, wf: Workflow, workflow_def: Dict[str, Any], initial wf.error = str(e) logger.error(f"Workflow {wf.wf_id} failed: {e}") import traceback + traceback.print_exc() finally: wf.end_time = asyncio.get_event_loop().time() @@ -339,24 +368,24 @@ async def run(self): try: await self.initialize() logger.info("Service Manager Running") - + while not self._shutdown: await self._process_requests() - await asyncio.sleep(0.1) # Polling interval + await asyncio.sleep(0.1) # Polling interval finally: await self.shutdown() async def shutdown(self): self._shutdown = True logger.info("Service Shutting Down...") - + # 1. Stop all learners if self.workflows: logger.info(f"Stopping {len(self.workflows)} workflows") for wf in self.workflows.values(): if wf.learner_instance: wf.learner_instance.stop() - + # 2. Cancel and wait for learner tasks if self._learner_tasks: logger.info(f"Canceling {len(self._learner_tasks)} learner tasks") @@ -373,5 +402,5 @@ async def shutdown(self): await self.engine.shutdown() self.engine = None logger.info("Workflow engine shut down") - + logger.info("Service shutdown complete") diff --git a/rose/service/models.py b/rose/service/models.py index 6d3a4ced..838041e7 100644 --- a/rose/service/models.py +++ b/rose/service/models.py @@ -1,33 +1,36 @@ -from enum import Enum, auto from dataclasses import dataclass, field -from typing import Optional, Any, Dict -import time +from enum import Enum +from typing import Any + class WorkflowState(Enum): """Lifecycle states for a ROSE Workflow/Learner.""" - SUBMITTED = "SUBMITTED" # Received but not yet started - INITIALIZING = "INITIALIZING" # Loading config and resources - RUNNING = "RUNNING" # Active execution - COMPLETED = "COMPLETED" # Finished successfully - FAILED = "FAILED" # Terminated with error - CANCELED = "CANCELED" # Stopped by user request + + SUBMITTED = "SUBMITTED" # Received but not yet started + INITIALIZING = "INITIALIZING" # Loading config and resources + RUNNING = "RUNNING" # Active execution + COMPLETED = "COMPLETED" # Finished successfully + FAILED = "FAILED" # Terminated with error + CANCELED = "CANCELED" # Stopped by user request + @dataclass class Workflow: """Represents a managed workflow (learner) instance.""" + wf_id: str state: WorkflowState = WorkflowState.SUBMITTED workflow_file: str = "" start_time: float = 0.0 end_time: float = 0.0 - stats: Dict[str, Any] = field(default_factory=dict) - error: Optional[str] = None - + stats: dict[str, Any] = field(default_factory=dict) + error: str | None = None + # Internal reference to the actual Learner object # This is not serialized to JSON learner_instance: Any = field(default=None, repr=False) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Serializable representation for external monitoring.""" return { "wf_id": self.wf_id, @@ -36,5 +39,5 @@ def to_dict(self) -> Dict[str, Any]: "start_time": self.start_time, "end_time": self.end_time, "stats": self.stats, - "error": self.error + "error": self.error, } diff --git a/tests/unit/test_rose_plugin.py b/tests/unit/test_rose_plugin.py index 0329accf..4a969428 100644 --- a/tests/unit/test_rose_plugin.py +++ b/tests/unit/test_rose_plugin.py @@ -1,23 +1,22 @@ -""" -Unit tests for the ROSE Edge Plugin. +"""Unit tests for the ROSE Edge Plugin. Tests the RoseSession, RoseClient, and WorkflowLoader classes. """ -import pytest import asyncio -from unittest.mock import Mock, AsyncMock, patch, MagicMock -from pathlib import Path +from unittest.mock import AsyncMock, Mock, patch -from rose.service.api.rest import RoseSession, RoseClient, PluginRose -from rose.service.models import Workflow, WorkflowState -from rose.service.manager import WorkflowLoader +import pytest +from rose.service.api.rest import PluginRose, RoseClient, RoseSession +from rose.service.manager import WorkflowLoader +from rose.service.models import Workflow, WorkflowState # ----------------------------------------------------------------------------- # Fixtures # ----------------------------------------------------------------------------- + @pytest.fixture def mock_engine(): """Create a mock WorkflowEngine.""" @@ -29,7 +28,7 @@ def mock_engine(): @pytest.fixture def rose_session(): """Create a RoseSession for testing.""" - session = RoseSession(sid='test-session-001') + session = RoseSession(sid="test-session-001") return session @@ -70,6 +69,7 @@ def sample_workflow_yaml(tmp_path): # WorkflowLoader Tests # ----------------------------------------------------------------------------- + class TestWorkflowLoader: """Tests for WorkflowLoader class.""" @@ -77,43 +77,43 @@ def test_load_yaml_valid(self, sample_workflow_yaml): """Test loading a valid YAML workflow file.""" wf_def = WorkflowLoader.load_yaml(sample_workflow_yaml) - assert 'learner' in wf_def - assert wf_def['learner']['type'] == 'SequentialActiveLearner' - assert 'components' in wf_def - assert 'simulation' in wf_def['components'] - assert 'config' in wf_def - assert wf_def['config']['max_iterations'] == 2 + assert "learner" in wf_def + assert wf_def["learner"]["type"] == "SequentialActiveLearner" + assert "components" in wf_def + assert "simulation" in wf_def["components"] + assert "config" in wf_def + assert wf_def["config"]["max_iterations"] == 2 def test_load_yaml_file_not_found(self): """Test loading a non-existent file raises error.""" with pytest.raises(FileNotFoundError): - WorkflowLoader.load_yaml('/nonexistent/path/workflow.yaml') + WorkflowLoader.load_yaml("/nonexistent/path/workflow.yaml") def test_create_learner_sequential(self, sample_workflow_yaml, mock_engine): """Test creating a SequentialActiveLearner from YAML.""" wf_def = WorkflowLoader.load_yaml(sample_workflow_yaml) - learner, config = WorkflowLoader.create_learner( - 'wf.test001', wf_def, mock_engine - ) + learner, config = WorkflowLoader.create_learner("wf.test001", wf_def, mock_engine) from rose.al.active_learner import SequentialActiveLearner + assert isinstance(learner, SequentialActiveLearner) - assert learner.learner_id == hash('wf.test001') + assert learner.learner_id == hash("wf.test001") def test_import_function_valid(self): """Test importing a valid function path.""" - func = WorkflowLoader._import_function('os.path.exists') + func = WorkflowLoader._import_function("os.path.exists") import os + assert func == os.path.exists def test_import_function_invalid(self): """Test importing an invalid function path raises error.""" with pytest.raises(ImportError): - WorkflowLoader._import_function('nonexistent.module.func') + WorkflowLoader._import_function("nonexistent.module.func") def test_create_script_task_factory(self): """Test creating a script task factory.""" - factory = WorkflowLoader._create_script_task_factory('/bin/echo') + factory = WorkflowLoader._create_script_task_factory("/bin/echo") # The factory should be an async function assert asyncio.iscoroutinefunction(factory) @@ -123,12 +123,13 @@ def test_create_script_task_factory(self): # RoseSession Tests # ----------------------------------------------------------------------------- + class TestRoseSession: """Tests for RoseSession class.""" def test_init(self, rose_session): """Test RoseSession initialization.""" - assert rose_session.sid == 'test-session-001' + assert rose_session.sid == "test-session-001" assert rose_session.is_active assert rose_session._workflows == {} assert rose_session._engine is None @@ -136,9 +137,10 @@ def test_init(self, rose_session): @pytest.mark.asyncio async def test_ensure_engine(self, rose_session): """Test lazy engine initialization.""" - with patch('rose.service.api.rest.LocalExecutionBackend') as mock_backend, \ - patch('rose.service.api.rest.WorkflowEngine') as mock_engine_cls: - + with ( + patch("rose.service.api.rest.LocalExecutionBackend") as mock_backend, + patch("rose.service.api.rest.WorkflowEngine") as mock_engine_cls, + ): mock_backend.return_value = Mock() mock_engine_cls.create = AsyncMock(return_value=Mock()) @@ -151,29 +153,30 @@ async def test_ensure_engine(self, rose_session): async def test_submit_workflow(self, rose_session, sample_workflow_yaml): """Test workflow submission.""" # Mock the engine and workflow execution - with patch.object(rose_session, '_ensure_engine', new_callable=AsyncMock), \ - patch.object(rose_session, '_run_workflow', new_callable=AsyncMock): - + with ( + patch.object(rose_session, "_ensure_engine", new_callable=AsyncMock), + patch.object(rose_session, "_run_workflow", new_callable=AsyncMock), + ): rose_session._engine = Mock() result = await rose_session.submit_workflow(sample_workflow_yaml) - assert 'wf_id' in result - assert result['wf_id'].startswith('wf.') - assert result['wf_id'] in rose_session._workflows - assert rose_session._workflows[result['wf_id']].state == WorkflowState.SUBMITTED + assert "wf_id" in result + assert result["wf_id"].startswith("wf.") + assert result["wf_id"] in rose_session._workflows + assert rose_session._workflows[result["wf_id"]].state == WorkflowState.SUBMITTED @pytest.mark.asyncio async def test_get_workflow_status_found(self, rose_session): """Test getting status of an existing workflow.""" # Add a workflow manually - wf = Workflow(wf_id='wf.test123', state=WorkflowState.RUNNING) - rose_session._workflows['wf.test123'] = wf + wf = Workflow(wf_id="wf.test123", state=WorkflowState.RUNNING) + rose_session._workflows["wf.test123"] = wf - status = await rose_session.get_workflow_status('wf.test123') + status = await rose_session.get_workflow_status("wf.test123") - assert status['wf_id'] == 'wf.test123' - assert status['state'] == 'RUNNING' + assert status["wf_id"] == "wf.test123" + assert status["state"] == "RUNNING" @pytest.mark.asyncio async def test_get_workflow_status_not_found(self, rose_session): @@ -181,7 +184,7 @@ async def test_get_workflow_status_not_found(self, rose_session): from fastapi import HTTPException with pytest.raises(HTTPException) as exc_info: - await rose_session.get_workflow_status('wf.nonexistent') + await rose_session.get_workflow_status("wf.nonexistent") assert exc_info.value.status_code == 404 @@ -189,20 +192,16 @@ async def test_get_workflow_status_not_found(self, rose_session): async def test_list_workflows(self, rose_session): """Test listing all workflows.""" # Add some workflows - rose_session._workflows['wf.001'] = Workflow( - wf_id='wf.001', state=WorkflowState.COMPLETED - ) - rose_session._workflows['wf.002'] = Workflow( - wf_id='wf.002', state=WorkflowState.RUNNING - ) + rose_session._workflows["wf.001"] = Workflow(wf_id="wf.001", state=WorkflowState.COMPLETED) + rose_session._workflows["wf.002"] = Workflow(wf_id="wf.002", state=WorkflowState.RUNNING) result = await rose_session.list_workflows() assert len(result) == 2 - assert 'wf.001' in result - assert 'wf.002' in result - assert result['wf.001']['state'] == 'COMPLETED' - assert result['wf.002']['state'] == 'RUNNING' + assert "wf.001" in result + assert "wf.002" in result + assert result["wf.001"]["state"] == "COMPLETED" + assert result["wf.002"]["state"] == "RUNNING" @pytest.mark.asyncio async def test_cancel_workflow(self, rose_session): @@ -212,14 +211,14 @@ async def test_cancel_workflow(self, rose_session): mock_task = AsyncMock() mock_task.done.return_value = False - wf = Workflow(wf_id='wf.cancel', state=WorkflowState.RUNNING) + wf = Workflow(wf_id="wf.cancel", state=WorkflowState.RUNNING) wf.learner_instance = mock_learner - rose_session._workflows['wf.cancel'] = wf - rose_session._learner_tasks['wf.cancel'] = mock_task + rose_session._workflows["wf.cancel"] = wf + rose_session._learner_tasks["wf.cancel"] = mock_task - result = await rose_session.cancel_workflow('wf.cancel') + result = await rose_session.cancel_workflow("wf.cancel") - assert result['wf_id'] == 'wf.cancel' + assert result["wf_id"] == "wf.cancel" mock_learner.stop.assert_called_once() mock_task.cancel.assert_called_once() @@ -228,11 +227,11 @@ async def test_cancel_workflow_not_running(self, rose_session): """Test canceling a completed workflow raises 400.""" from fastapi import HTTPException - wf = Workflow(wf_id='wf.done', state=WorkflowState.COMPLETED) - rose_session._workflows['wf.done'] = wf + wf = Workflow(wf_id="wf.done", state=WorkflowState.COMPLETED) + rose_session._workflows["wf.done"] = wf with pytest.raises(HTTPException) as exc_info: - await rose_session.cancel_workflow('wf.done') + await rose_session.cancel_workflow("wf.done") assert exc_info.value.status_code == 400 @@ -244,10 +243,10 @@ async def test_close_session(self, rose_session): mock_task = AsyncMock() mock_task.done.return_value = False - wf = Workflow(wf_id='wf.close', state=WorkflowState.RUNNING) + wf = Workflow(wf_id="wf.close", state=WorkflowState.RUNNING) wf.learner_instance = mock_learner - rose_session._workflows['wf.close'] = wf - rose_session._learner_tasks['wf.close'] = mock_task + rose_session._workflows["wf.close"] = wf + rose_session._learner_tasks["wf.close"] = mock_task rose_session._engine = AsyncMock() rose_session._engine.shutdown = AsyncMock() @@ -272,6 +271,7 @@ async def test_session_closed_check(self, rose_session): # RoseClient Tests # ----------------------------------------------------------------------------- + class TestRoseClient: """Tests for RoseClient class.""" @@ -280,7 +280,7 @@ def mock_http(self): """Create a mock HTTP client.""" http = Mock() response = Mock() - response.json.return_value = {'sid': 'session.abc123'} + response.json.return_value = {"sid": "session.abc123"} response.raise_for_status = Mock() http.post.return_value = response http.get.return_value = response @@ -289,44 +289,41 @@ def mock_http(self): @pytest.fixture def rose_client(self, mock_http): """Create a RoseClient with mocked HTTP.""" - client = RoseClient(mock_http, '/test/rose') - client._sid = 'session.test' + client = RoseClient(mock_http, "/test/rose") + client._sid = "session.test" return client def test_submit_workflow(self, rose_client, mock_http): """Test submitting a workflow via client.""" - mock_http.post.return_value.json.return_value = {'wf_id': 'wf.new'} + mock_http.post.return_value.json.return_value = {"wf_id": "wf.new"} - result = rose_client.submit_workflow('/path/to/wf.yaml') + result = rose_client.submit_workflow("/path/to/wf.yaml") - assert result == {'wf_id': 'wf.new'} + assert result == {"wf_id": "wf.new"} mock_http.post.assert_called() def test_submit_workflow_no_session(self, mock_http): """Test submit without session raises error.""" - client = RoseClient(mock_http, '/test/rose') + client = RoseClient(mock_http, "/test/rose") # No session registered with pytest.raises(RuntimeError, match="No active session"): - client.submit_workflow('/path/to/wf.yaml') + client.submit_workflow("/path/to/wf.yaml") def test_get_workflow_status(self, rose_client, mock_http): """Test getting workflow status via client.""" - mock_http.get.return_value.json.return_value = { - 'wf_id': 'wf.123', - 'state': 'RUNNING' - } + mock_http.get.return_value.json.return_value = {"wf_id": "wf.123", "state": "RUNNING"} - result = rose_client.get_workflow_status('wf.123') + result = rose_client.get_workflow_status("wf.123") - assert result['wf_id'] == 'wf.123' - assert result['state'] == 'RUNNING' + assert result["wf_id"] == "wf.123" + assert result["state"] == "RUNNING" def test_list_workflows(self, rose_client, mock_http): """Test listing workflows via client.""" mock_http.get.return_value.json.return_value = { - 'wf.001': {'state': 'COMPLETED'}, - 'wf.002': {'state': 'RUNNING'} + "wf.001": {"state": "COMPLETED"}, + "wf.002": {"state": "RUNNING"}, } result = rose_client.list_workflows() @@ -335,22 +332,22 @@ def test_list_workflows(self, rose_client, mock_http): def test_cancel_workflow(self, rose_client, mock_http): """Test canceling workflow via client.""" - mock_http.post.return_value.json.return_value = {'wf_id': 'wf.cancel'} + mock_http.post.return_value.json.return_value = {"wf_id": "wf.cancel"} - result = rose_client.cancel_workflow('wf.cancel') + result = rose_client.cancel_workflow("wf.cancel") - assert result['wf_id'] == 'wf.cancel' + assert result["wf_id"] == "wf.cancel" def test_notification_callbacks(self, rose_client): """Test registering notification callbacks.""" callback = Mock() # Should not raise - with patch.object(rose_client, 'register_notification_callback'): + with patch.object(rose_client, "register_notification_callback"): rose_client.on_workflow_state(callback) rose_client.register_notification_callback.assert_called_with(callback) - with patch.object(rose_client, 'unregister_notification_callback'): + with patch.object(rose_client, "unregister_notification_callback"): rose_client.off_workflow_state(callback) rose_client.unregister_notification_callback.assert_called_with(callback) @@ -359,21 +356,22 @@ def test_notification_callbacks(self, rose_client): # PluginRose Tests # ----------------------------------------------------------------------------- + class TestPluginRose: """Tests for PluginRose class.""" def test_plugin_attributes(self): """Test plugin class attributes.""" - assert PluginRose.plugin_name == 'rose' + assert PluginRose.plugin_name == "rose" assert PluginRose.session_class == RoseSession assert PluginRose.client_class == RoseClient - assert PluginRose.version == '0.2.0' + assert PluginRose.version == "0.2.0" assert PluginRose.session_ttl == 0 def test_ui_config(self): """Test UI configuration is defined.""" assert PluginRose.ui_config is not None - assert PluginRose.ui_config.title == 'ROSE Active Learning' + assert PluginRose.ui_config.title == "ROSE Active Learning" assert len(PluginRose.ui_config.forms) == 1 assert len(PluginRose.ui_config.monitors) == 1 assert PluginRose.ui_config.notifications is not None From 728e3558ddaf2266f17011a13a86a84aa55a1b68 Mon Sep 17 00:00:00 2001 From: AymenFJA Date: Mon, 16 Mar 2026 17:03:19 +0100 Subject: [PATCH 13/30] fix pytests and add fastapi as req. --- pyproject.toml | 3 +++ tests/unit/test_rose_plugin.py | 23 ++++++++++++++--------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 266342c7..5fffed39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,12 +43,15 @@ mlflow = ["mlflow>=2.0"] clearml = ["clearml>=1.14"] tracking = ["mlflow>=2.0", "clearml>=1.14"] +service = ["fastapi>=0.100"] + # All test deps dev = [ "pytest", "pytest-asyncio", "pytest-cov", "pre-commit", + "fastapi>=0.100", ] doc = [ diff --git a/tests/unit/test_rose_plugin.py b/tests/unit/test_rose_plugin.py index 4a969428..4ca05396 100644 --- a/tests/unit/test_rose_plugin.py +++ b/tests/unit/test_rose_plugin.py @@ -4,7 +4,7 @@ """ import asyncio -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest @@ -208,7 +208,7 @@ async def test_cancel_workflow(self, rose_session): """Test canceling a running workflow.""" # Add a running workflow with mock learner mock_learner = Mock() - mock_task = AsyncMock() + mock_task = MagicMock(spec=asyncio.Task) mock_task.done.return_value = False wf = Workflow(wf_id="wf.cancel", state=WorkflowState.RUNNING) @@ -238,25 +238,30 @@ async def test_cancel_workflow_not_running(self, rose_session): @pytest.mark.asyncio async def test_close_session(self, rose_session): """Test closing session stops all workflows.""" - # Add a running workflow + # Add a running workflow with a real (cancelled) asyncio.Task mock_learner = Mock() - mock_task = AsyncMock() - mock_task.done.return_value = False + + async def _noop(): + await asyncio.sleep(10) + + real_task = asyncio.create_task(_noop()) wf = Workflow(wf_id="wf.close", state=WorkflowState.RUNNING) wf.learner_instance = mock_learner rose_session._workflows["wf.close"] = wf - rose_session._learner_tasks["wf.close"] = mock_task + rose_session._learner_tasks["wf.close"] = real_task - rose_session._engine = AsyncMock() - rose_session._engine.shutdown = AsyncMock() + mock_engine = AsyncMock() + mock_engine.shutdown = AsyncMock() + rose_session._engine = mock_engine result = await rose_session.close() assert result == {} assert not rose_session.is_active + assert real_task.cancelled() mock_learner.stop.assert_called_once() - rose_session._engine.shutdown.assert_called_once() + mock_engine.shutdown.assert_called_once() @pytest.mark.asyncio async def test_session_closed_check(self, rose_session): From e3c98c62ac71215e1feadd33cedc43a59604c68c Mon Sep 17 00:00:00 2001 From: AymenFJA Date: Mon, 16 Mar 2026 17:09:59 +0100 Subject: [PATCH 14/30] adding edge as req. --- pyproject.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5fffed39..adb76611 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,10 @@ mlflow = ["mlflow>=2.0"] clearml = ["clearml>=1.14"] tracking = ["mlflow>=2.0", "clearml>=1.14"] -service = ["fastapi>=0.100"] +service = [ + "fastapi>=0.100", + "radical.edge @ git+https://github.com/radical-cybertools/radical.edge", +] # All test deps dev = [ @@ -52,6 +55,7 @@ dev = [ "pytest-cov", "pre-commit", "fastapi>=0.100", + "radical.edge @ git+https://github.com/radical-cybertools/radical.edge", ] doc = [ From c18eef0c0742ab9d25cfe1d9a2683083dc53db81 Mon Sep 17 00:00:00 2001 From: AymenFJA Date: Mon, 16 Mar 2026 21:58:20 +0100 Subject: [PATCH 15/30] fix wrong api --- rose/service/api/rest.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/rose/service/api/rest.py b/rose/service/api/rest.py index 935f7e14..327d16e8 100644 --- a/rose/service/api/rest.py +++ b/rose/service/api/rest.py @@ -196,10 +196,20 @@ async def _run_workflow(self, wf: Workflow): parallel = config.get("parallel_learners", learner_cfg.get("parallel_learners", 2)) configs = [initial_config] * parallel if initial_config else None - results = await learner.start( + async for state in learner.start( parallel_learners=parallel, max_iter=max_iter, learner_configs=configs - ) - wf.stats = {"parallel_results": [str(r) for r in results]} + ): + wf.stats = ( + state.to_dict() if hasattr(state, "to_dict") else {"result": str(state)} + ) + learner_id = getattr(state, "learner_id", "?") + iteration = getattr(state, "iteration", "?") + metric = getattr(state, "metric_value", "?") + log.info( + f"[{self.sid}] {wf_id} learner {learner_id}," + f" iteration {iteration} (metric={metric})" + ) + self._notify_state(wf) else: # Sequential learner - async iterator From 625a19948f034ef8f11a9533c922396abd1ba88f Mon Sep 17 00:00:00 2001 From: Andre Merzky Date: Mon, 23 Mar 2026 11:21:13 +0100 Subject: [PATCH 16/30] snapshot of plugin iteration --- examples/service/README.md | 2 + examples/service/example_rose_plugin.py | 168 ++++++++++++++++-------- 2 files changed, 112 insertions(+), 58 deletions(-) diff --git a/examples/service/README.md b/examples/service/README.md index 43703e21..1fe04d85 100644 --- a/examples/service/README.md +++ b/examples/service/README.md @@ -14,6 +14,7 @@ The service uses file-based IPC: the manager polls a local directory for request | `run_service.py` | Integration example: launches, submits, monitors, and shuts down programmatically | | `verify_service.py` | Demonstrates workflow cancellation flow | | `example_rose_plugin.py` | REST API example using RADICAL-Edge plugin | +| `example_minimal.py` | Minimal example (30 lines) for quick reference | --- @@ -268,4 +269,5 @@ python example_rose_plugin.py --workflow debug_workflow.yaml | File | Description | |------|-------------| | `example_rose_plugin.py` | REST API example using RADICAL-Edge plugin | +| `example_minimal.py` | Minimal example (30 lines) for quick reference | | `debug_workflow.yaml` | Fast test workflow (2 iterations, ~6 seconds) | diff --git a/examples/service/example_rose_plugin.py b/examples/service/example_rose_plugin.py index 5d381dcf..c6d95050 100755 --- a/examples/service/example_rose_plugin.py +++ b/examples/service/example_rose_plugin.py @@ -1,18 +1,26 @@ #!/usr/bin/env python3 """ -Example: Test ROSE Edge Plugin +Example: ROSE Workflow Execution via RADICAL-Edge Plugin -Connects to a running RADICAL-Edge bridge, submits a workflow via the -ROSE plugin, and monitors its status. +This example demonstrates how to submit and monitor Active Learning +workflows using the ROSE plugin for RADICAL-Edge. + +Architecture: + Client (this script) + | HTTP/REST + v + RADICAL-Edge Bridge + | WebSocket + v + Edge Service (ROSE plugin with embedded workflow execution) Prerequisites: - 1. Bridge running: radical-edge-bridge - 2. Edge with ROSE plugin: radical-edge-service (with ROSE plugin loaded) - 3. ROSE ServiceManager: rose launch --job-id local_job_0 + 1. RADICAL-Edge bridge running + 2. RADICAL-Edge service running with ROSE plugin loaded Usage: export RADICAL_BRIDGE_URL=https://localhost:8443 - python example_rose_plugin.py [--workflow FILE] [--job-id ID] + python example_rose_plugin.py --workflow debug_workflow.yaml """ import os @@ -22,29 +30,55 @@ import argparse from pathlib import Path +# Configure logging logging.basicConfig( - level=logging.DEBUG, - format='%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s', + level=logging.INFO, + format='%(asctime)s | %(levelname)-8s | %(message)s', datefmt='%H:%M:%S' ) -for name in ['httpx', 'httpcore', 'urllib3']: - logging.getLogger(name).setLevel(logging.DEBUG) - log = logging.getLogger('rose.example') +# Reduce noise from HTTP libraries +for name in ['httpx', 'httpcore', 'urllib3', 'hpack']: + logging.getLogger(name).setLevel(logging.WARNING) + -def notification_cb(topic: str, data: dict): - """Handle workflow notifications.""" - log.info(f'[NOTIFY] {topic}: {data}') +def on_state_change(topic: str, data: dict): + """Callback for workflow state change notifications.""" + wf_id = data.get('wf_id', '?') + state = data.get('state', '?') + stats = data.get('stats', {}) + + if stats: + iteration = stats.get('iteration', '-') + metric = stats.get('metric_value', '-') + log.info(f'[{wf_id}] {state} (iteration={iteration}, metric={metric})') + else: + log.info(f'[{wf_id}] {state}') def main(): - parser = argparse.ArgumentParser(description='Test ROSE Edge Plugin') - parser.add_argument('--workflow', '-w', default='debug_workflow.yaml') - parser.add_argument('--job-id', '-j', default='local_job_0') - parser.add_argument('--bridge-url', '-b', - default=os.environ.get('RADICAL_BRIDGE_URL', - 'https://localhost:8443')) + parser = argparse.ArgumentParser( + description='Submit ROSE workflow via RADICAL-Edge plugin', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__ + ) + parser.add_argument( + '--workflow', '-w', + default='debug_workflow.yaml', + help='Path to workflow YAML file (default: debug_workflow.yaml)' + ) + parser.add_argument( + '--bridge-url', '-b', + default=os.environ.get('RADICAL_BRIDGE_URL', 'https://localhost:8443'), + help='Bridge URL (default: $RADICAL_BRIDGE_URL or https://localhost:8443)' + ) + parser.add_argument( + '--timeout', '-t', + type=int, + default=300, + help='Timeout in seconds (default: 300)' + ) args = parser.parse_args() # Resolve workflow path @@ -52,85 +86,103 @@ def main(): if not workflow.exists(): workflow = Path(__file__).parent / args.workflow if not workflow.exists(): - log.error(f'Workflow not found: {args.workflow}') + log.error(f'Workflow file not found: {args.workflow}') sys.exit(1) + workflow = workflow.resolve() + log.info(f'Bridge: {args.bridge_url}') log.info(f'Workflow: {workflow}') - log.info(f'Job ID: {args.job_id}') - log.info('-' * 60) + log.info('-' * 50) + # Import dependencies from radical.edge import BridgeClient + import rose.service.api.rest # Register ROSE plugin client class - # Import ROSE plugin to register client class locally - import rose.service.api.rest # noqa: F401 - + # Connect to bridge try: bc = BridgeClient(url=args.bridge_url) edges = bc.list_edges() except Exception as e: - log.error(f'Cannot connect to bridge: {e}') - log.error('Make sure bridge and edge service are running.') + log.error(f'Cannot connect to bridge at {args.bridge_url}: {e}') sys.exit(1) if not edges: - log.error('No edges connected to bridge') + log.error('No edge services connected to bridge') sys.exit(1) edge_id = edges[0] - log.info(f'Using edge: {edge_id}') + log.info(f'Edge: {edge_id}') + # Get ROSE plugin client try: ec = bc.get_edge_client(edge_id) - rose = ec.get_plugin('rose', job_id=args.job_id) + rose = ec.get_plugin('rose') except Exception as e: log.error(f'Cannot get ROSE plugin: {e}') - log.error('Make sure ROSE plugin is loaded on edge service.') + log.error('Ensure ROSE plugin is loaded on the edge service') sys.exit(1) log.info(f'Session: {rose.sid}') - rose.on_workflow_state(notification_cb) + log.info('-' * 50) + + # Register notification callback + rose.on_workflow_state(on_state_change) try: # Submit workflow - log.info(f'Submitting {workflow}...') - result = rose.submit_workflow(str(workflow.absolute())) + log.info('Submitting workflow...') + result = rose.submit_workflow(str(workflow)) wf_id = result['wf_id'] - log.info(f'Submitted: {wf_id}') + log.info(f'Workflow ID: {wf_id}') - # Monitor status - log.info('Monitoring (Ctrl+C to cancel)...') - terminal = {'COMPLETED', 'FAILED', 'CANCELED'} - last_state = None + # Poll for completion + terminal_states = {'COMPLETED', 'FAILED', 'CANCELED'} + start_time = time.time() - for i in range(120): + while time.time() - start_time < args.timeout: time.sleep(2) + try: status = rose.get_workflow_status(wf_id) - state = status.get('state', 'UNKNOWN') - if state != last_state: - log.info(f'State: {state}') - last_state = state - if state in terminal: - if state == 'FAILED': - log.error(f'Error: {status.get("error")}') - break except Exception as e: - log.warning(f'Status error: {e}') - - # Final state - log.info('-' * 60) + log.warning(f'Status check failed: {e}') + continue + + state = status.get('state', 'UNKNOWN') + + if state in terminal_states: + log.info('-' * 50) + if state == 'COMPLETED': + log.info(f'Workflow {wf_id} completed successfully') + elif state == 'FAILED': + log.error(f'Workflow {wf_id} failed: {status.get("error")}') + else: + log.warning(f'Workflow {wf_id} was canceled') + break + else: + log.warning(f'Timeout after {args.timeout}s') + + # Show final workflow list + log.info('-' * 50) + log.info('All workflows:') for wid, info in rose.list_workflows().items(): - log.info(f'{wid}: {info.get("state")}') + log.info(f' {wid}: {info.get("state")}') except KeyboardInterrupt: - log.warning('Interrupted') + log.warning('Interrupted by user') + # Optionally cancel the workflow + try: + rose.cancel_workflow(wf_id) + log.info(f'Canceled workflow {wf_id}') + except Exception: + pass finally: - rose.off_workflow_state(notification_cb) + rose.off_workflow_state(on_state_change) rose.close() bc.close() - log.info('Done.') + log.info('Done') if __name__ == '__main__': From 0381ca0b6eb098e37483ccd6b13d000a74b6dafa Mon Sep 17 00:00:00 2001 From: Andre Merzky Date: Mon, 23 Mar 2026 17:12:00 +0100 Subject: [PATCH 17/30] sync with edge evolution, fix notifications and registration --- pyproject.toml | 3 + rose/service/api/rest.py | 78 ++++------ rose/service/api/rose.js | 54 +++++++ rose/service/manager.py | 250 ++++++++++++++------------------- tests/unit/test_rose_plugin.py | 5 +- 5 files changed, 192 insertions(+), 198 deletions(-) create mode 100644 rose/service/api/rose.js diff --git a/pyproject.toml b/pyproject.toml index adb76611..1014797d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,9 @@ doc = [ [tool.setuptools.packages.find] include = ["rose*"] +[tool.setuptools.package-data] +rose = ["service/api/*.js"] + [tool.ruff] line-length = 100 target-version = "py310" diff --git a/rose/service/api/rest.py b/rose/service/api/rest.py index 327d16e8..abf8f072 100644 --- a/rose/service/api/rest.py +++ b/rose/service/api/rest.py @@ -65,6 +65,7 @@ import logging import time import uuid +from pathlib import Path from fastapi import FastAPI, HTTPException, Request from radical.asyncflow import LocalExecutionBackend, WorkflowEngine @@ -81,7 +82,6 @@ ) from starlette.responses import JSONResponse -from rose.al.active_learner import ParallelActiveLearner from rose.service.manager import WorkflowLoader from rose.service.models import Workflow, WorkflowState @@ -163,7 +163,7 @@ async def submit_workflow(self, workflow_file: str) -> dict: self._learner_tasks[wf_id] = task log.info(f"[{self.sid}] Submitted workflow {wf_id}: {workflow_file}") - return {"wf_id": wf_id} + return {"wf_id": wf_id, "state": WorkflowState.SUBMITTED.value} # -------------------------------------------------------------------------- # @@ -186,40 +186,23 @@ async def _run_workflow(self, wf: Workflow): wf.start_time = time.time() self._notify_state(wf) - config = wf_def.get("config", {}) - learner_cfg = wf_def.get("learner", {}) - max_iter = config.get("max_iterations", learner_cfg.get("max_iterations", 10)) - - log.info(f"[{self.sid}] Running workflow {wf_id} (max_iterations={max_iter})") + log.info("[%s] Running workflow %s", self.sid, wf_id) - if isinstance(learner, ParallelActiveLearner): - parallel = config.get("parallel_learners", learner_cfg.get("parallel_learners", 2)) - configs = [initial_config] * parallel if initial_config else None + def on_iteration(state): + wf.stats = ( + state.to_dict() if hasattr(state, "to_dict") else {"result": str(state)} + ) + log.info( + "[%s] %s learner %s, iteration %s (metric=%s)", + self.sid, + wf_id, + getattr(state, "learner_id", "?"), + getattr(state, "iteration", "?"), + getattr(state, "metric_value", "?"), + ) + self._notify_state(wf) - async for state in learner.start( - parallel_learners=parallel, max_iter=max_iter, learner_configs=configs - ): - wf.stats = ( - state.to_dict() if hasattr(state, "to_dict") else {"result": str(state)} - ) - learner_id = getattr(state, "learner_id", "?") - iteration = getattr(state, "iteration", "?") - metric = getattr(state, "metric_value", "?") - log.info( - f"[{self.sid}] {wf_id} learner {learner_id}," - f" iteration {iteration} (metric={metric})" - ) - self._notify_state(wf) - - else: - # Sequential learner - async iterator - async for state in learner.start(max_iter=max_iter, initial_config=initial_config): - wf.stats = state.to_dict() - log.info( - f"[{self.sid}] {wf_id} iteration {state.iteration} " - f"(metric={state.metric_value})" - ) - self._notify_state(wf) + await WorkflowLoader.run_learner(learner, wf_def, initial_config, on_iteration) # Completed wf.state = WorkflowState.COMPLETED @@ -235,10 +218,7 @@ async def _run_workflow(self, wf: Workflow): wf.state = WorkflowState.FAILED wf.error = str(e) wf.end_time = time.time() - log.error(f"[{self.sid}] Workflow {wf_id} failed: {e}") - import traceback - - traceback.print_exc() + log.exception("[%s] Workflow %s failed: %s", self.sid, wf_id, e) finally: self._notify_state(wf) @@ -322,10 +302,6 @@ async def cancel_workflow(self, wf_id: str) -> dict: task.cancel() log.info(f"[{self.sid}] Canceling workflow {wf_id}") - - if self._notify: - self._notify("workflow_state", {"wf_id": wf_id, "state": "CANCELING"}) - return {"wf_id": wf_id} # -------------------------------------------------------------------------- @@ -339,13 +315,14 @@ async def close(self) -> dict: if wf.learner_instance and wf.state == WorkflowState.RUNNING: wf.learner_instance.stop() - # Cancel all tasks - for task in self._learner_tasks.values(): + # Snapshot before cancelling — tasks remove themselves on completion + tasks = list(self._learner_tasks.values()) + for task in tasks: if not task.done(): task.cancel() - if self._learner_tasks: - await asyncio.gather(*self._learner_tasks.values(), return_exceptions=True) + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) self._learner_tasks.clear() # Shutdown engine @@ -402,7 +379,7 @@ def submit_workflow(self, workflow_file: str) -> dict: resp = self._http.post( self._url(f"submit/{self.sid}"), json={"workflow_file": workflow_file} ) - resp.raise_for_status() + self._raise(resp) return resp.json() @@ -421,7 +398,7 @@ def get_workflow_status(self, wf_id: str) -> dict: raise RuntimeError("No active session") resp = self._http.get(self._url(f"status/{self.sid}/{wf_id}")) - resp.raise_for_status() + self._raise(resp) return resp.json() @@ -437,7 +414,7 @@ def list_workflows(self) -> dict: raise RuntimeError("No active session") resp = self._http.get(self._url(f"workflows/{self.sid}")) - resp.raise_for_status() + self._raise(resp) return resp.json() @@ -456,7 +433,7 @@ def cancel_workflow(self, wf_id: str) -> dict: raise RuntimeError("No active session") resp = self._http.post(self._url(f"cancel/{self.sid}/{wf_id}")) - resp.raise_for_status() + self._raise(resp) return resp.json() @@ -483,6 +460,7 @@ class PluginRose(Plugin): client_class = RoseClient version = "0.2.0" session_ttl = 0 # No timeout - workflows can run for hours/days + ui_module = str(Path(__file__).parent / "rose.js") ui_config = UIConfig( icon="🌹", diff --git a/rose/service/api/rose.js b/rose/service/api/rose.js new file mode 100644 index 00000000..12e04591 --- /dev/null +++ b/rose/service/api/rose.js @@ -0,0 +1,54 @@ +/** + * ROSE Plugin Module for Radical Edge Explorer + * + * Handles workflow submission results and live state updates via SSE. + */ + +export const name = 'rose'; + +export const notificationConfig = { + topic: 'workflow_state', + idField: 'wf_id', +}; + +export function onNotification(data, page, api) { + if (data.topic !== 'workflow_state') return; + + const wfId = data.data?.wf_id || ''; + const state = (data.data?.state || '?').toUpperCase(); + if (!wfId) return; + + const entryId = `rose-task-${api.edgeName}-${wfId}`; + const entry = document.getElementById(entryId); + if (!entry) return; + + const stateEl = entry.querySelector('.rose-task-state'); + if (!stateEl) return; + + const isOk = state === 'COMPLETED'; + const isRunning = state === 'RUNNING' || state === 'INITIALIZING'; + const isFailed = state === 'FAILED' || state === 'CANCELED'; + + stateEl.className = `rose-task-state badge ${ + isOk ? 'badge-green' : + isRunning ? 'badge-blue' : + isFailed ? 'badge-red' : 'badge-orange'}`; + stateEl.textContent = state; + + const logEl = entry.querySelector('.rose-task-log'); + if (!logEl) return; + + const ts = new Date().toLocaleTimeString(); + const stats = data.data?.stats; + const error = data.data?.error; + + let logHtml = `[${ts}] ${state}`; + if (stats?.iteration !== undefined) { + const metric = stats.metric_value !== undefined ? ` metric=${stats.metric_value}` : ''; + logHtml += `
iteration ${stats.iteration}${metric}`; + } + if (error) { + logHtml += `
${api.escHtml(error)}
`; + } + logEl.innerHTML = logHtml; +} diff --git a/rose/service/manager.py b/rose/service/manager.py index 1d7f4e86..4aa0235c 100644 --- a/rose/service/manager.py +++ b/rose/service/manager.py @@ -19,38 +19,24 @@ class WorkflowLoader: - """Helper to load a Learner from a YAML definition.""" + """Helper to load and run a Learner from a YAML workflow definition.""" @staticmethod def load_yaml(path: str) -> dict[str, Any]: - """Load YAML file (mocking yaml load with json for now or basic parsing if yaml lib not - avail? - - User environment might have PyYAML. Assuming yaml is available or using json for simplicity - if needed. The user request says 'workflow.yaml', so we should try to support YAML. If - PyYAML is not installed, we might fallback or error. Standard python doesn't have yaml. - """ - # For this implementation, I will assume PyYAML is available as it's common in this stack, - # or I will implement a very simple parser if restricted. - # Given "ROSE" context, PyYAML is likely a dependency. - # But to be safe and depend only on stdlib as requested ("standard libraries only" was for - # IPC, but let's stick to it), check if yaml module exists, otherwise parse as JSON. - # However, the user explicitly said "workflow.yaml". - # I will try to import yaml. + """Parse a workflow YAML file and return its contents as a dict.""" try: import yaml - with open(path) as f: + with open(path, encoding="utf-8") as f: return yaml.safe_load(f) except ImportError: - # Fallback: simpler parsing or expect JSON content in .yaml (not ideal) - logger.warning("PyYAML not found, trying JSON parsing for workflow file") - with open(path) as f: + logger.warning("PyYAML not found, falling back to JSON parsing") + with open(path, encoding="utf-8") as f: return json.load(f) @staticmethod def _import_function(path_str: str) -> Callable: - """Import a function from a module path string 'package.module.func'.""" + """Import a callable from a dotted module path (e.g. 'pkg.module.func').""" try: module_name, func_name = path_str.rsplit(".", 1) module = importlib.import_module(module_name) @@ -60,42 +46,42 @@ def _import_function(path_str: str) -> Callable: @staticmethod def _create_script_task_factory(script_path: str) -> Callable: - """Create a task function that returns the script path + arguments. + """Return an async task function that builds a shell command from the script path. - Args: - script_path: The base command or script path. + The returned function collects string positional arguments and keyword arguments, + assembles them into a command list, and returns the joined string for execution + by the workflow engine (``as_executable=True``). """ async def task_func(*args, **kwargs): - # Extract string arguments to append to the command. - # Skip Task objects (dependencies). cmd_parts = [script_path] for arg in args: if isinstance(arg, str): cmd_parts.append(arg) - for k, v in kwargs.items(): if isinstance(v, bool): if v: cmd_parts.append(f"--{k}") else: - cmd_parts.append(f"--{k} {v}") - + cmd_parts.extend([f"--{k}", str(v)]) return " ".join(cmd_parts) return task_func @classmethod - def create_learner(cls, wf_id: str, workflow_def: dict[str, Any], asyncflow: WorkflowEngine): - """Create and configure a Learner based on the YAML definition.""" + def create_learner( + cls, wf_id: str, workflow_def: dict[str, Any], asyncflow: WorkflowEngine + ) -> tuple: + """Instantiate and configure a Learner from a workflow definition dict. + Returns: + (learner, initial_config) tuple. + """ learner_def = workflow_def.get("learner", {}) l_type = learner_def.get("type", "SequentialActiveLearner") l_path = learner_def.get("path") - # 1. Instantiate Learner Class if l_path: - # Load custom learner class try: module_name, class_name = l_path.rsplit(".", 1) module = importlib.import_module(module_name) @@ -104,67 +90,47 @@ def create_learner(cls, wf_id: str, workflow_def: dict[str, Any], asyncflow: Wor raise ImportError(f"Could not import learner class '{l_path}': {e}") from e elif l_type == "SequentialActiveLearner": learner_cls = SequentialActiveLearner + elif l_type == "ParallelActiveLearner": + learner_cls = ParallelActiveLearner else: - # Try to find it in rose.al or similar if we want to support more built-ins raise ValueError(f"Unknown learner type '{l_type}' and no path provided.") learner = learner_cls(asyncflow) learner.learner_id = wf_id - # rose/active_learner.py: learner_id (Optional[int]). - # I should probably hash the wf_id or just set it if it accepts Any? - # The type hint says Optional[int]. Let's ignore type hint for a moment or hash it. - learner.learner_id = hash(wf_id) components = workflow_def.get("components", {}) - # 2. Register Components - # Expecting structure: - # components: - # simulation: - # type: function | script - # path: ... - # config: ... - for name in ["simulation", "training", "active_learn", "criterion"]: comp_def = components.get(name) if not comp_def: continue - ctype = comp_def.get("type", "script") # Default to script? + ctype = comp_def.get("type", "script") cpath = comp_def.get("path") - - task_func = None - as_executable = True + as_executable = ctype != "function" if ctype == "function": task_func = cls._import_function(cpath) - as_executable = False else: - # Script task_func = cls._create_script_task_factory(cpath) - as_executable = True - # Register using the appropriate decorator if name == "simulation": - logger.info(f"Registering simulation task for workflow {wf_id}") + logger.info("Registering simulation task for workflow %s", wf_id) learner.simulation_task(as_executable=as_executable)(task_func) elif name == "training": - logger.info(f"Registering training task for workflow {wf_id}") + logger.info("Registering training task for workflow %s", wf_id) learner.training_task(as_executable=as_executable)(task_func) elif name == "active_learn": - logger.info(f"Registering active_learn task for workflow {wf_id}") + logger.info("Registering active_learn task for workflow %s", wf_id) learner.active_learn_task(as_executable=as_executable)(task_func) elif name == "criterion": - # Special handling for criterion - logger.info(f"Registering criterion task for workflow {wf_id}") + logger.info("Registering criterion task for workflow %s", wf_id) threshold = comp_def.get("threshold", 0.0) metric = comp_def.get("metric", "CUSTOM") learner.as_stop_criterion( metric_name=metric, threshold=threshold, as_executable=as_executable )(task_func) - # 3. Build initial LearnerConfig from component defs - # This ensures that args/kwargs specified in YAML are used l_config = LearnerConfig() for name in ["simulation", "training", "active_learn", "criterion"]: comp_def = components.get(name) @@ -177,6 +143,37 @@ def create_learner(cls, wf_id: str, workflow_def: dict[str, Any], asyncflow: Wor return learner, l_config + @staticmethod + async def run_learner( + learner, + wf_def: dict[str, Any], + initial_config: LearnerConfig | None, + on_iteration, + ) -> None: + """Drive a learner's iteration loop, calling ``on_iteration(state)`` per step. + + Handles both SequentialActiveLearner and ParallelActiveLearner. + ``on_iteration`` may be a regular function or a coroutine. + """ + config = wf_def.get("config", {}) + learner_cfg = wf_def.get("learner", {}) + max_iter = config.get("max_iterations", learner_cfg.get("max_iterations", 10)) + + if isinstance(learner, ParallelActiveLearner): + parallel = config.get("parallel_learners", learner_cfg.get("parallel_learners", 2)) + configs = [initial_config] * parallel if initial_config else None + async for state in learner.start( + parallel_learners=parallel, max_iter=max_iter, learner_configs=configs + ): + result = on_iteration(state) + if asyncio.iscoroutine(result): + await result + else: + async for state in learner.start(max_iter=max_iter, initial_config=initial_config): + result = on_iteration(state) + if asyncio.iscoroutine(result): + await result + class ServiceManager: def __init__(self, job_id: str): @@ -187,28 +184,27 @@ def __init__(self, job_id: str): self.workflows: dict[str, Workflow] = {} self.engine: WorkflowEngine | None = None - self._learner_tasks: list[asyncio.Task] = [] + self._learner_tasks: dict[str, asyncio.Task] = {} self._shutdown = False async def initialize(self): - """Setup directories and backend.""" + """Setup directories and workflow engine.""" self.requests_dir.mkdir(parents=True, exist_ok=True) backend = LocalExecutionBackend() self.engine = await WorkflowEngine.create(backend) - logger.info(f"Service initialized at {self.service_root}") + logger.info("Service initialized at %s", self.service_root) async def _process_requests(self): - """Pick up json files from requests_dir.""" + """Pick up JSON request files from requests_dir and dispatch them.""" if not self.requests_dir.exists(): return - # Sort by mtime to process in order? req_files = sorted(self.requests_dir.glob("*.json"), key=os.path.getmtime) for req_file in req_files: try: - with open(req_file) as f: + with open(req_file, encoding="utf-8") as f: req = json.load(f) action = req.get("action") @@ -222,13 +218,10 @@ async def _process_requests(self): logger.info("Shutdown request received via IPC") self._shutdown = True - # Remove request file after processing req_file.unlink() except Exception as e: - logger.error(f"Error processing request {req_file}: {e}") - # Move to failed_requests? Or just delete? - # For now, delete to avoid loop + logger.error("Error processing request %s: %s", req_file, e) try: req_file.unlink() except Exception: @@ -240,10 +233,7 @@ async def _handle_submit(self, req_id: str, payload: dict[str, Any]): logger.error("No workflow file in submit payload") return - # Use request ID as part of wf_id or generate new? - # User goal: "assigned a unique workflow identifier (wf_id)" wf_id = ServiceClient.get_wf_id(req_id) - wf = Workflow(wf_id=wf_id, state=WorkflowState.INITIALIZING, workflow_file=wf_file) self.workflows[wf_id] = wf self._update_registry() @@ -253,17 +243,11 @@ async def _handle_submit(self, req_id: str, payload: dict[str, Any]): learner, initial_l_config = WorkflowLoader.create_learner(wf_id, wf_def, self.engine) wf.learner_instance = learner - # Merge with top-level config if needed (e.g. if we want to override via top-level) - # For now, initial_l_config from components is primary. - - # Start the learner loop as a background task - task = asyncio.create_task( - self._run_learner(wf, wf_def.get("config", {}), initial_l_config) - ) - self._learner_tasks.append(task) + task = asyncio.create_task(self._run_learner(wf, wf_def, initial_l_config)) + self._learner_tasks[wf_id] = task except Exception as e: - logger.error(f"Failed to submit workflow {wf_id}: {e}") + logger.error("Failed to submit workflow %s: %s", wf_id, e) wf.state = WorkflowState.FAILED wf.error = str(e) self._update_registry() @@ -271,107 +255,83 @@ async def _handle_submit(self, req_id: str, payload: dict[str, Any]): async def _handle_cancel(self, payload: dict[str, Any]): wf_id = payload.get("wf_id") wf = self.workflows.get(wf_id) - if wf and wf.state in [ + if wf and wf.state in ( WorkflowState.RUNNING, WorkflowState.INITIALIZING, WorkflowState.SUBMITTED, - ]: - logger.info(f"Canceling workflow {wf_id}") + ): + logger.info("Canceling workflow %s", wf_id) if wf.learner_instance: - wf.learner_instance.stop() # Cooperative cancel + wf.learner_instance.stop() + task = self._learner_tasks.get(wf_id) + if task and not task.done(): + task.cancel() wf.state = WorkflowState.CANCELED self._update_registry() async def _run_learner( self, wf: Workflow, - workflow_def: dict[str, Any], + wf_def: dict[str, Any], initial_l_config: LearnerConfig | None = None, ): """Driver loop for a single workflow.""" wf.state = WorkflowState.RUNNING wf.start_time = asyncio.get_event_loop().time() - logger.info(f"Starting workflow {wf.wf_id} ({wf.workflow_file})") + logger.info("Starting workflow %s (%s)", wf.wf_id, wf.workflow_file) self._update_registry() try: - learner_cfg = workflow_def.get("learner", {}) - max_iter = learner_cfg.get("max_iterations", workflow_def.get("max_iterations", 0)) - - # Identify learner type and call appropriately - if isinstance(wf.learner_instance, ParallelActiveLearner): - parallel_learners = learner_cfg.get( - "parallel_learners", workflow_def.get("parallel_learners", 2) + def on_iteration(state): + wf.stats = ( + state.to_dict() if hasattr(state, "to_dict") else {"result": str(state)} ) - - # ParallelActiveLearner.start doesn't take initial_config, - # but we can map it to learner_configs - l_configs = None - if initial_l_config: - l_configs = [initial_l_config] * parallel_learners - - async for state in wf.learner_instance.start( - parallel_learners=parallel_learners, - max_iter=max_iter, - learner_configs=l_configs, - ): - wf.stats = ( - state.to_dict() if hasattr(state, "to_dict") else {"result": str(state)} - ) - learner_id = getattr(state, "learner_id", "?") - iteration = getattr(state, "iteration", "?") - metric = getattr(state, "metric_value", "?") - logger.info( - f"Workflow {wf.wf_id} - Learner {learner_id}," - f" iteration {iteration} completed (metric: {metric})" - ) - self._update_registry() logger.info( - f"Workflow {wf.wf_id} - Parallel execution of" - f" {parallel_learners} learners finished" + "Workflow %s - learner %s, iteration %s (metric=%s)", + wf.wf_id, + getattr(state, "learner_id", "?"), + getattr(state, "iteration", "?"), + getattr(state, "metric_value", "?"), ) - else: - # SequentialActiveLearner or other async iterator - async for state in wf.learner_instance.start( - max_iter=max_iter, initial_config=initial_l_config - ): - wf.stats = state.to_dict() - logger.info( - f"Workflow {wf.wf_id} - Iteration {state.iteration}" - f" completed (metric: {state.metric_value})" - ) - self._update_registry() + self._update_registry() + await WorkflowLoader.run_learner( + wf.learner_instance, wf_def, initial_l_config, on_iteration + ) wf.state = WorkflowState.COMPLETED - logger.info(f"Workflow {wf.wf_id} completed successfully") + logger.info("Workflow %s completed successfully", wf.wf_id) + + except asyncio.CancelledError: + wf.state = WorkflowState.CANCELED + logger.info("Workflow %s canceled", wf.wf_id) + except Exception as e: wf.state = WorkflowState.FAILED wf.error = str(e) - logger.error(f"Workflow {wf.wf_id} failed: {e}") - import traceback + logger.exception("Workflow %s failed: %s", wf.wf_id, e) - traceback.print_exc() finally: wf.end_time = asyncio.get_event_loop().time() + self._learner_tasks.pop(wf.wf_id, None) self._update_registry() def _update_registry(self): - """Dump registry to json.""" + """Atomically write workflow registry to disk.""" data = {wf_id: wf.to_dict() for wf_id, wf in self.workflows.items()} tmp_file = self.registry_file.with_suffix(".tmp") - with open(tmp_file, "w") as f: + with open(tmp_file, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) tmp_file.replace(self.registry_file) async def run(self): - """Main Service Loop.""" + """Main service loop.""" try: await self.initialize() logger.info("Service Manager Running") while not self._shutdown: await self._process_requests() - await asyncio.sleep(0.1) # Polling interval + await asyncio.sleep(0.1) finally: await self.shutdown() @@ -379,24 +339,22 @@ async def shutdown(self): self._shutdown = True logger.info("Service Shutting Down...") - # 1. Stop all learners if self.workflows: - logger.info(f"Stopping {len(self.workflows)} workflows") + logger.info("Stopping %d workflows", len(self.workflows)) for wf in self.workflows.values(): if wf.learner_instance: wf.learner_instance.stop() - # 2. Cancel and wait for learner tasks if self._learner_tasks: - logger.info(f"Canceling {len(self._learner_tasks)} learner tasks") - for task in self._learner_tasks: + logger.info("Canceling %d learner tasks", len(self._learner_tasks)) + tasks = list(self._learner_tasks.values()) + for task in tasks: if not task.done(): task.cancel() - await asyncio.gather(*self._learner_tasks, return_exceptions=True) + await asyncio.gather(*tasks, return_exceptions=True) self._learner_tasks.clear() logger.info("All learner tasks stopped") - # 3. Shutdown engine if self.engine: logger.info("Shutting down workflow engine") await self.engine.shutdown() diff --git a/tests/unit/test_rose_plugin.py b/tests/unit/test_rose_plugin.py index 4ca05396..7e4ba958 100644 --- a/tests/unit/test_rose_plugin.py +++ b/tests/unit/test_rose_plugin.py @@ -97,7 +97,7 @@ def test_create_learner_sequential(self, sample_workflow_yaml, mock_engine): from rose.al.active_learner import SequentialActiveLearner assert isinstance(learner, SequentialActiveLearner) - assert learner.learner_id == hash("wf.test001") + assert learner.learner_id == "wf.test001" def test_import_function_valid(self): """Test importing a valid function path.""" @@ -286,7 +286,8 @@ def mock_http(self): http = Mock() response = Mock() response.json.return_value = {"sid": "session.abc123"} - response.raise_for_status = Mock() + response.status_code = 200 + response.is_error = False http.post.return_value = response http.get.return_value = response return http From e609eff1c39e92b62aab5291dc7bcc55367334f3 Mon Sep 17 00:00:00 2001 From: Andre Merzky Date: Tue, 24 Mar 2026 10:47:24 +0100 Subject: [PATCH 18/30] snap --- rose/service/api/rest.py | 32 ++++++++++++++++++++++++++++++++ rose/service/api/rose.js | 33 +++++++++++++++++++++++++++------ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/rose/service/api/rest.py b/rose/service/api/rest.py index abf8f072..b775941a 100644 --- a/rose/service/api/rest.py +++ b/rose/service/api/rest.py @@ -181,6 +181,38 @@ async def _run_workflow(self, wf: Workflow): learner, initial_config = WorkflowLoader.create_learner(wf_id, wf_def, self._engine) wf.learner_instance = learner + # Wrap learner task registration to emit per-task completion events + task_count = 0 + _orig_register = learner._register_task + + def _tracked_register(task_obj, deps=None): + nonlocal task_count + task_count += 1 + tid = task_count + future = _orig_register(task_obj, deps) + log.debug("[%s] task %d registered for wf %s", self.sid, tid, wf_id) + + def _on_done(fut): + try: + if fut.cancelled(): + return + exc = fut.exception() + is_ok = exc is None + raw = str(fut.result() or "") if is_ok else str(exc) + excerpt = next((l.strip() for l in raw.splitlines() if l.strip()), "")[:120] + log.info("[%s] task %d %s: %s", self.sid, tid, + "ok" if is_ok else "err", excerpt[:60]) + if self._notify: + self._notify("task_event", {"wf_id": wf_id, "task_id": tid, + "ok": is_ok, "excerpt": excerpt}) + except Exception: + log.exception("[%s] task_event callback failed for task %d", self.sid, tid) + + future.add_done_callback(_on_done) + return future + + learner._register_task = _tracked_register + # Run wf.state = WorkflowState.RUNNING wf.start_time = time.time() diff --git a/rose/service/api/rose.js b/rose/service/api/rose.js index 12e04591..1f4df806 100644 --- a/rose/service/api/rose.js +++ b/rose/service/api/rose.js @@ -12,16 +12,29 @@ export const notificationConfig = { }; export function onNotification(data, page, api) { - if (data.topic !== 'workflow_state') return; - const wfId = data.data?.wf_id || ''; - const state = (data.data?.state || '?').toUpperCase(); if (!wfId) return; - const entryId = `rose-task-${api.edgeName}-${wfId}`; - const entry = document.getElementById(entryId); + const entryId = `rose-task-${api.edgeName}-${wfId}`; + const entry = document.getElementById(entryId); if (!entry) return; + if (data.topic === 'task_event') { + const logEl = entry.querySelector('.rose-task-log'); + if (!logEl) return; + const d = data.data; + const color = d.ok ? 'var(--green, #4caf50)' : 'var(--red, #f44336)'; + const icon = d.ok ? '✓' : '✗'; + const excerpt = api.escHtml(d.excerpt || ''); + logEl.insertAdjacentHTML('beforeend', + `
[task.${d.task_id}] ${icon} ${excerpt}
` + ); + return; + } + + if (data.topic !== 'workflow_state') return; + + const state = (data.data?.state || '?').toUpperCase(); const stateEl = entry.querySelector('.rose-task-state'); if (!stateEl) return; @@ -50,5 +63,13 @@ export function onNotification(data, page, api) { if (error) { logHtml += `
${api.escHtml(error)}
`; } - logEl.innerHTML = logHtml; + + // Write state info into a stable child so task event lines below survive + let si = logEl.querySelector('.rose-state-info'); + if (!si) { + si = document.createElement('div'); + si.className = 'rose-state-info'; + logEl.prepend(si); + } + si.innerHTML = logHtml; } From 4b666131582eb081aa92eb5f9edce73a25fc9b6d Mon Sep 17 00:00:00 2001 From: Andre Merzky Date: Tue, 24 Mar 2026 10:53:45 +0100 Subject: [PATCH 19/30] snap --- rose/service/api/rose.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/rose/service/api/rose.js b/rose/service/api/rose.js index 1f4df806..7f1fcd74 100644 --- a/rose/service/api/rose.js +++ b/rose/service/api/rose.js @@ -20,6 +20,8 @@ export function onNotification(data, page, api) { if (!entry) return; if (data.topic === 'task_event') { + console.log('[rose] task_event received:', data.data?.task_id, + data.data?.ok ? 'ok' : 'err', data.data?.excerpt?.slice(0, 60)); const logEl = entry.querySelector('.rose-task-log'); if (!logEl) return; const d = data.data; @@ -57,19 +59,21 @@ export function onNotification(data, page, api) { let logHtml = `[${ts}] ${state}`; if (stats?.iteration !== undefined) { - const metric = stats.metric_value !== undefined ? ` metric=${stats.metric_value}` : ''; + const metric = stats.metric_value != null ? ` metric=${stats.metric_value}` : ''; logHtml += `
iteration ${stats.iteration}${metric}`; } if (error) { logHtml += `
${api.escHtml(error)}
`; } - // Write state info into a stable child so task event lines below survive + // Write state info into a stable child so task event lines below survive. + // On first write, clear the "Waiting…" placeholder. let si = logEl.querySelector('.rose-state-info'); if (!si) { + logEl.innerHTML = ''; si = document.createElement('div'); si.className = 'rose-state-info'; - logEl.prepend(si); + logEl.appendChild(si); } si.innerHTML = logHtml; } From e4d87849d19a111f50a64da6b2027c85e5c814af Mon Sep 17 00:00:00 2001 From: Andre Merzky Date: Tue, 24 Mar 2026 11:14:11 +0100 Subject: [PATCH 20/30] revert debugs --- rose/service/api/rest.py | 24 +++++++++--------------- rose/service/api/rose.js | 2 -- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/rose/service/api/rest.py b/rose/service/api/rest.py index b775941a..1c4a0648 100644 --- a/rose/service/api/rest.py +++ b/rose/service/api/rest.py @@ -190,23 +190,17 @@ def _tracked_register(task_obj, deps=None): task_count += 1 tid = task_count future = _orig_register(task_obj, deps) - log.debug("[%s] task %d registered for wf %s", self.sid, tid, wf_id) def _on_done(fut): - try: - if fut.cancelled(): - return - exc = fut.exception() - is_ok = exc is None - raw = str(fut.result() or "") if is_ok else str(exc) - excerpt = next((l.strip() for l in raw.splitlines() if l.strip()), "")[:120] - log.info("[%s] task %d %s: %s", self.sid, tid, - "ok" if is_ok else "err", excerpt[:60]) - if self._notify: - self._notify("task_event", {"wf_id": wf_id, "task_id": tid, - "ok": is_ok, "excerpt": excerpt}) - except Exception: - log.exception("[%s] task_event callback failed for task %d", self.sid, tid) + if fut.cancelled(): + return + exc = fut.exception() + is_ok = exc is None + raw = str(fut.result() or "") if is_ok else str(exc) + excerpt = next((l.strip() for l in raw.splitlines() if l.strip()), "")[:120] + if self._notify: + self._notify("task_event", {"wf_id": wf_id, "task_id": tid, + "ok": is_ok, "excerpt": excerpt}) future.add_done_callback(_on_done) return future diff --git a/rose/service/api/rose.js b/rose/service/api/rose.js index 7f1fcd74..ae570a7f 100644 --- a/rose/service/api/rose.js +++ b/rose/service/api/rose.js @@ -20,8 +20,6 @@ export function onNotification(data, page, api) { if (!entry) return; if (data.topic === 'task_event') { - console.log('[rose] task_event received:', data.data?.task_id, - data.data?.ok ? 'ok' : 'err', data.data?.excerpt?.slice(0, 60)); const logEl = entry.querySelector('.rose-task-log'); if (!logEl) return; const d = data.data; From 5ae565d40df1f914d8514d1bbe5fb880f8219fc1 Mon Sep 17 00:00:00 2001 From: Andre Merzky Date: Tue, 24 Mar 2026 14:24:03 +0100 Subject: [PATCH 21/30] fix tilde expansion --- rose/service/api/rest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rose/service/api/rest.py b/rose/service/api/rest.py index 1c4a0648..1c1c98db 100644 --- a/rose/service/api/rest.py +++ b/rose/service/api/rest.py @@ -144,6 +144,8 @@ async def submit_workflow(self, workflow_file: str) -> dict: self._check_active() await self._ensure_engine() + workflow_file = str(Path(workflow_file).expanduser()) + # Generate workflow ID wf_id = f"wf.{uuid.uuid4().hex[:8]}" From a9f4ed89f4481069874f5257903913647b1045b4 Mon Sep 17 00:00:00 2001 From: Andre Merzky Date: Wed, 25 Mar 2026 17:20:42 +0100 Subject: [PATCH 22/30] respond to comments --- examples/service/example_rose_plugin.py | 13 ++++----- rose/service/api/rest.py | 4 +-- rose/service/manager.py | 38 ++++++++++++------------- 3 files changed, 26 insertions(+), 29 deletions(-) diff --git a/examples/service/example_rose_plugin.py b/examples/service/example_rose_plugin.py index c258b4ed..f232b15f 100755 --- a/examples/service/example_rose_plugin.py +++ b/examples/service/example_rose_plugin.py @@ -54,12 +54,10 @@ def on_state_change(topic: str, data: dict): iteration = stats.get('iteration', '-') metric = stats.get('metric_value', '-') log.info(f'[{wf_id}] {state} (iteration={iteration}, metric={metric})') - else: + elif wf_id and state: log.info(f'[{wf_id}] {state}') - -def notification_cb(topic: str, data: dict): - """Handle workflow notifications.""" - log.info(f"[NOTIFY] {topic}: {data}") + else: + log.info(f"[NOTIFY] {topic}: {data}") def main(): @@ -98,7 +96,6 @@ def main(): log.info(f"Bridge: {args.bridge_url}") log.info(f"Workflow: {workflow}") - log.info(f"Job ID: {args.job_id}") log.info("-" * 60) # Import dependencies @@ -123,14 +120,14 @@ def main(): # Get ROSE plugin client try: ec = bc.get_edge_client(edge_id) - rose = ec.get_plugin("rose", job_id=args.job_id) + rose = ec.get_plugin("rose") except Exception as e: log.error(f"Cannot get ROSE plugin: {e}") log.error("Make sure ROSE plugin is loaded on edge service.") sys.exit(1) log.info(f"Session: {rose.sid}") - rose.on_workflow_state(notification_cb) + rose.on_workflow_state(on_state_change) try: # Submit workflow diff --git a/rose/service/api/rest.py b/rose/service/api/rest.py index 1c1c98db..46a0bf8c 100644 --- a/rose/service/api/rest.py +++ b/rose/service/api/rest.py @@ -214,7 +214,7 @@ def _on_done(fut): wf.start_time = time.time() self._notify_state(wf) - log.info("[%s] Running workflow %s", self.sid, wf_id) + log.info("[{self.sid}] Running workflow {wf_id}") def on_iteration(state): wf.stats = ( @@ -246,7 +246,7 @@ def on_iteration(state): wf.state = WorkflowState.FAILED wf.error = str(e) wf.end_time = time.time() - log.exception("[%s] Workflow %s failed: %s", self.sid, wf_id, e) + log.exception("[{self.sid}] Workflow {wf_id} failed") finally: self._notify_state(wf) diff --git a/rose/service/manager.py b/rose/service/manager.py index 4aa0235c..9c7818ca 100644 --- a/rose/service/manager.py +++ b/rose/service/manager.py @@ -115,16 +115,16 @@ def create_learner( task_func = cls._create_script_task_factory(cpath) if name == "simulation": - logger.info("Registering simulation task for workflow %s", wf_id) + logger.info("Registering simulation task for workflow {wf_id}") learner.simulation_task(as_executable=as_executable)(task_func) elif name == "training": - logger.info("Registering training task for workflow %s", wf_id) + logger.info("Registering training task for workflow {wf_id}") learner.training_task(as_executable=as_executable)(task_func) elif name == "active_learn": - logger.info("Registering active_learn task for workflow %s", wf_id) + logger.info("Registering active_learn task for workflow {wf_id}") learner.active_learn_task(as_executable=as_executable)(task_func) elif name == "criterion": - logger.info("Registering criterion task for workflow %s", wf_id) + logger.info("Registering criterion task for workflow {wf_id}") threshold = comp_def.get("threshold", 0.0) metric = comp_def.get("metric", "CUSTOM") learner.as_stop_criterion( @@ -193,7 +193,7 @@ async def initialize(self): backend = LocalExecutionBackend() self.engine = await WorkflowEngine.create(backend) - logger.info("Service initialized at %s", self.service_root) + logger.info("Service initialized at {self.service_root}") async def _process_requests(self): """Pick up JSON request files from requests_dir and dispatch them.""" @@ -221,7 +221,7 @@ async def _process_requests(self): req_file.unlink() except Exception as e: - logger.error("Error processing request %s: %s", req_file, e) + logger.exception("Error processing request {req_file}") try: req_file.unlink() except Exception: @@ -247,7 +247,7 @@ async def _handle_submit(self, req_id: str, payload: dict[str, Any]): self._learner_tasks[wf_id] = task except Exception as e: - logger.error("Failed to submit workflow %s: %s", wf_id, e) + logger.exception("Failed to submit workflow {wf_id}") wf.state = WorkflowState.FAILED wf.error = str(e) self._update_registry() @@ -260,7 +260,7 @@ async def _handle_cancel(self, payload: dict[str, Any]): WorkflowState.INITIALIZING, WorkflowState.SUBMITTED, ): - logger.info("Canceling workflow %s", wf_id) + logger.info("Canceling workflow {wf_id}") if wf.learner_instance: wf.learner_instance.stop() task = self._learner_tasks.get(wf_id) @@ -278,7 +278,7 @@ async def _run_learner( """Driver loop for a single workflow.""" wf.state = WorkflowState.RUNNING wf.start_time = asyncio.get_event_loop().time() - logger.info("Starting workflow %s (%s)", wf.wf_id, wf.workflow_file) + logger.info("Starting workflow {wf.wf_id} ({wf.workflow_file})") self._update_registry() try: @@ -299,16 +299,16 @@ def on_iteration(state): wf.learner_instance, wf_def, initial_l_config, on_iteration ) wf.state = WorkflowState.COMPLETED - logger.info("Workflow %s completed successfully", wf.wf_id) + logger.info("Workflow {wf.wf_id} completed successfully") except asyncio.CancelledError: wf.state = WorkflowState.CANCELED - logger.info("Workflow %s canceled", wf.wf_id) + logger.info("Workflow {wf.wf_id} canceled") except Exception as e: wf.state = WorkflowState.FAILED wf.error = str(e) - logger.exception("Workflow %s failed: %s", wf.wf_id, e) + logger.exception("Workflow {wf.wf_id} failed") finally: wf.end_time = asyncio.get_event_loop().time() @@ -340,18 +340,18 @@ async def shutdown(self): logger.info("Service Shutting Down...") if self.workflows: - logger.info("Stopping %d workflows", len(self.workflows)) + logger.info("Stopping {len(self.workflows)} workflows") for wf in self.workflows.values(): if wf.learner_instance: wf.learner_instance.stop() if self._learner_tasks: - logger.info("Canceling %d learner tasks", len(self._learner_tasks)) - tasks = list(self._learner_tasks.values()) - for task in tasks: - if not task.done(): - task.cancel() - await asyncio.gather(*tasks, return_exceptions=True) + logger.info("Canceling {len(self._learner_tasks)} learner tasks") + lerner_tasks = list(self._learner_tasks.values()) + for lerner_task in lerner_tasks: + if not lerner_task.done(): + lerner_task.cancel() + await asyncio.gather(*lerner_tasks, return_exceptions=True) self._learner_tasks.clear() logger.info("All learner tasks stopped") From 2d2e3d572ec1ccc45b86ac8e218cd3b537f249a8 Mon Sep 17 00:00:00 2001 From: Andre Merzky Date: Wed, 25 Mar 2026 17:24:38 +0100 Subject: [PATCH 23/30] respond to comments --- rose/service/api/rest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rose/service/api/rest.py b/rose/service/api/rest.py index 46a0bf8c..7f7f0138 100644 --- a/rose/service/api/rest.py +++ b/rose/service/api/rest.py @@ -199,7 +199,8 @@ def _on_done(fut): exc = fut.exception() is_ok = exc is None raw = str(fut.result() or "") if is_ok else str(exc) - excerpt = next((l.strip() for l in raw.splitlines() if l.strip()), "")[:120] + excerpt = next((line.strip() for line in raw.splitlines() + if line.strip()), "")[:120] if self._notify: self._notify("task_event", {"wf_id": wf_id, "task_id": tid, "ok": is_ok, "excerpt": excerpt}) From 20191a18a2b959f8d0e4067953dbbba51b4b9d07 Mon Sep 17 00:00:00 2001 From: Andre Merzky Date: Wed, 25 Mar 2026 17:26:47 +0100 Subject: [PATCH 24/30] respond to comments --- rose/service/api/rest.py | 27 ++++++++++++++------------- rose/service/manager.py | 5 ++--- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/rose/service/api/rest.py b/rose/service/api/rest.py index 7f7f0138..df3ad52a 100644 --- a/rose/service/api/rest.py +++ b/rose/service/api/rest.py @@ -184,26 +184,29 @@ async def _run_workflow(self, wf: Workflow): wf.learner_instance = learner # Wrap learner task registration to emit per-task completion events - task_count = 0 - _orig_register = learner._register_task + task_count = 0 + _orig_register = learner._register_task def _tracked_register(task_obj, deps=None): nonlocal task_count task_count += 1 - tid = task_count + tid = task_count future = _orig_register(task_obj, deps) def _on_done(fut): if fut.cancelled(): return - exc = fut.exception() - is_ok = exc is None - raw = str(fut.result() or "") if is_ok else str(exc) - excerpt = next((line.strip() for line in raw.splitlines() - if line.strip()), "")[:120] + exc = fut.exception() + is_ok = exc is None + raw = str(fut.result() or "") if is_ok else str(exc) + excerpt = next((line.strip() for line in raw.splitlines() if line.strip()), "")[ + :120 + ] if self._notify: - self._notify("task_event", {"wf_id": wf_id, "task_id": tid, - "ok": is_ok, "excerpt": excerpt}) + self._notify( + "task_event", + {"wf_id": wf_id, "task_id": tid, "ok": is_ok, "excerpt": excerpt}, + ) future.add_done_callback(_on_done) return future @@ -218,9 +221,7 @@ def _on_done(fut): log.info("[{self.sid}] Running workflow {wf_id}") def on_iteration(state): - wf.stats = ( - state.to_dict() if hasattr(state, "to_dict") else {"result": str(state)} - ) + wf.stats = state.to_dict() if hasattr(state, "to_dict") else {"result": str(state)} log.info( "[%s] %s learner %s, iteration %s (metric=%s)", self.sid, diff --git a/rose/service/manager.py b/rose/service/manager.py index 9c7818ca..44520082 100644 --- a/rose/service/manager.py +++ b/rose/service/manager.py @@ -282,10 +282,9 @@ async def _run_learner( self._update_registry() try: + def on_iteration(state): - wf.stats = ( - state.to_dict() if hasattr(state, "to_dict") else {"result": str(state)} - ) + wf.stats = state.to_dict() if hasattr(state, "to_dict") else {"result": str(state)} logger.info( "Workflow %s - learner %s, iteration %s (metric=%s)", wf.wf_id, From 23f465587b816a5fe35619582270c157471f294f Mon Sep 17 00:00:00 2001 From: AymenFJA Date: Wed, 25 Mar 2026 19:24:39 +0100 Subject: [PATCH 25/30] fix pre-commit --- examples/service/example_rose_plugin.py | 59 ++++++++++++------------- rose/service/manager.py | 2 +- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/examples/service/example_rose_plugin.py b/examples/service/example_rose_plugin.py index f232b15f..aa52db5b 100755 --- a/examples/service/example_rose_plugin.py +++ b/examples/service/example_rose_plugin.py @@ -38,7 +38,7 @@ ) # Reduce noise from HTTP libraries -for name in ['httpx', 'httpcore', 'urllib3', 'hpack']: +for name in ["httpx", "httpcore", "urllib3", "hpack"]: logging.getLogger(name).setLevel(logging.WARNING) log = logging.getLogger("rose.example") @@ -46,41 +46,40 @@ def on_state_change(topic: str, data: dict): """Callback for workflow state change notifications.""" - wf_id = data.get('wf_id', '?') - state = data.get('state', '?') - stats = data.get('stats', {}) + wf_id = data.get("wf_id", "?") + state = data.get("state", "?") + stats = data.get("stats", {}) if stats: - iteration = stats.get('iteration', '-') - metric = stats.get('metric_value', '-') - log.info(f'[{wf_id}] {state} (iteration={iteration}, metric={metric})') + iteration = stats.get("iteration", "-") + metric = stats.get("metric_value", "-") + log.info(f"[{wf_id}] {state} (iteration={iteration}, metric={metric})") elif wf_id and state: - log.info(f'[{wf_id}] {state}') + log.info(f"[{wf_id}] {state}") else: log.info(f"[NOTIFY] {topic}: {data}") def main(): parser = argparse.ArgumentParser( - description='Submit ROSE workflow via RADICAL-Edge plugin', + description="Submit ROSE workflow via RADICAL-Edge plugin", formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=__doc__ + epilog=__doc__, ) parser.add_argument( - '--workflow', '-w', - default='debug_workflow.yaml', - help='Path to workflow YAML file (default: debug_workflow.yaml)' + "--workflow", + "-w", + default="debug_workflow.yaml", + help="Path to workflow YAML file (default: debug_workflow.yaml)", ) parser.add_argument( - '--bridge-url', '-b', - default=os.environ.get('RADICAL_BRIDGE_URL', 'https://localhost:8443'), - help='Bridge URL (default: $RADICAL_BRIDGE_URL or https://localhost:8443)' + "--bridge-url", + "-b", + default=os.environ.get("RADICAL_BRIDGE_URL", "https://localhost:8443"), + help="Bridge URL (default: $RADICAL_BRIDGE_URL or https://localhost:8443)", ) parser.add_argument( - '--timeout', '-t', - type=int, - default=300, - help='Timeout in seconds (default: 300)' + "--timeout", "-t", type=int, default=300, help="Timeout in seconds (default: 300)" ) args = parser.parse_args() @@ -100,6 +99,7 @@ def main(): # Import dependencies from radical.edge import BridgeClient + import rose.service.api.rest # Register ROSE plugin client class # Connect to bridge @@ -143,7 +143,6 @@ def main(): last_state = None while time.time() - start_time < args.timeout: - time.sleep(2) try: @@ -159,19 +158,19 @@ def main(): except Exception as e: log.warning(f"Status error: {e}") - state = status.get('state', 'UNKNOWN') + state = status.get("state", "UNKNOWN") if state in terminal: - log.info('-' * 50) - if state == 'COMPLETED': - log.info(f'Workflow {wf_id} completed successfully') - elif state == 'FAILED': - log.error(f'Workflow {wf_id} failed: {status.get("error")}') + log.info("-" * 50) + if state == "COMPLETED": + log.info(f"Workflow {wf_id} completed successfully") + elif state == "FAILED": + log.error(f"Workflow {wf_id} failed: {status.get('error')}") else: - log.warning(f'Workflow {wf_id} was canceled') + log.warning(f"Workflow {wf_id} was canceled") break else: - log.warning(f'Timeout after {args.timeout}s') + log.warning(f"Timeout after {args.timeout}s") # Show final workflow list log.info("-" * 60) @@ -179,7 +178,7 @@ def main(): log.info(f" {wid}: {info.get('state')}") except KeyboardInterrupt: - log.warning('Interrupted by user') + log.warning("Interrupted by user") finally: rose.off_workflow_state(on_state_change) diff --git a/rose/service/manager.py b/rose/service/manager.py index 44520082..36e62c6b 100644 --- a/rose/service/manager.py +++ b/rose/service/manager.py @@ -220,7 +220,7 @@ async def _process_requests(self): req_file.unlink() - except Exception as e: + except Exception: logger.exception("Error processing request {req_file}") try: req_file.unlink() From d669685f4c818d6659403428aee4c38b38569bbd Mon Sep 17 00:00:00 2001 From: AymenFJA Date: Wed, 25 Mar 2026 21:49:18 +0100 Subject: [PATCH 26/30] fix service not terminating --- rose/service/api/rest.py | 58 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/rose/service/api/rest.py b/rose/service/api/rest.py index df3ad52a..bbb18f63 100644 --- a/rose/service/api/rest.py +++ b/rose/service/api/rest.py @@ -63,6 +63,7 @@ import asyncio import logging +import signal import time import uuid from pathlib import Path @@ -243,6 +244,7 @@ def on_iteration(state): wf.state = WorkflowState.CANCELED wf.end_time = time.time() log.info(f"[{self.sid}] Workflow {wf_id} canceled") + raise except Exception as e: wf.state = WorkflowState.FAILED @@ -352,14 +354,51 @@ async def close(self) -> dict: task.cancel() if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - self._learner_tasks.clear() + try: + await asyncio.wait_for( + asyncio.gather(*tasks, return_exceptions=True), + timeout=5.0, + ) + except asyncio.TimeoutError: + log.warning("[%s] Timed out waiting for learner tasks to cancel", self.sid) + self._learner_tasks.clear() # Shutdown engine if self._engine: - await self._engine.shutdown() + # Snapshot existing tasks so we can drain asyncflow's leftovers afterwards + current = asyncio.current_task() + pre_shutdown_tasks = set(asyncio.all_tasks()) - {current} + + try: + await asyncio.wait_for(self._engine.shutdown(), timeout=5.0) + except asyncio.TimeoutError: + log.warning("[%s] Engine shutdown timed out, forcing exit", self.sid) self._engine = None + # asyncflow registers SIGINT/SIGTERM/SIGHUP via loop.add_signal_handler(), + # replacing uvicorn's own handlers and never restoring them. Remove them + # so that the next Ctrl-C / SIGTERM is handled by uvicorn as normal. + loop = asyncio.get_event_loop() + for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGINT): + try: + loop.remove_signal_handler(sig) + except Exception: + pass + + # asyncflow's cancel_all_tasks() cancels futures but never awaits them, + # leaving cancelled tasks in the event-loop queue. Drain them now so the + # loop is clean and uvicorn can exit without spurious delays. + leftover = [ + t + for t in asyncio.all_tasks() + if t is not current and t not in pre_shutdown_tasks and not t.done() + ] + if leftover: + log.debug("[%s] Draining %d leftover engine tasks", self.sid, len(leftover)) + for t in leftover: + t.cancel() + await asyncio.gather(*leftover, return_exceptions=True) + return await super().close() @@ -541,6 +580,19 @@ def __init__(self, app: FastAPI, instance_name: str = "rose"): self.add_route_post("cancel/{sid}/{wf_id}", self.cancel_workflow) self._log_routes() + app.add_event_handler("shutdown", self._on_shutdown) + + # -------------------------------------------------------------------------- + # + async def _on_shutdown(self) -> None: + """Close all active sessions when the FastAPI app shuts down.""" + log.info("[rose] Shutting down %d active session(s)...", len(self._sessions)) + for session in list(self._sessions.values()): + try: + await asyncio.wait_for(session.close(), timeout=10.0) + except (asyncio.TimeoutError, Exception) as exc: + log.warning("[rose] Session %s close error during shutdown: %s", session.sid, exc) + log.info("[rose] All sessions closed") # -------------------------------------------------------------------------- # From a8decfd9e7ab8f40ca48fec282fda4ed315302d2 Mon Sep 17 00:00:00 2001 From: AymenFJA Date: Wed, 25 Mar 2026 21:54:48 +0100 Subject: [PATCH 27/30] fix wrong and missing f-string style --- rose/service/api/rest.py | 23 ++++++++++------------- rose/service/manager.py | 36 +++++++++++++++++------------------- 2 files changed, 27 insertions(+), 32 deletions(-) diff --git a/rose/service/api/rest.py b/rose/service/api/rest.py index bbb18f63..ff0d5426 100644 --- a/rose/service/api/rest.py +++ b/rose/service/api/rest.py @@ -219,17 +219,14 @@ def _on_done(fut): wf.start_time = time.time() self._notify_state(wf) - log.info("[{self.sid}] Running workflow {wf_id}") + log.info(f"[{self.sid}] Running workflow {wf_id}") def on_iteration(state): wf.stats = state.to_dict() if hasattr(state, "to_dict") else {"result": str(state)} log.info( - "[%s] %s learner %s, iteration %s (metric=%s)", - self.sid, - wf_id, - getattr(state, "learner_id", "?"), - getattr(state, "iteration", "?"), - getattr(state, "metric_value", "?"), + f"[{self.sid}] {wf_id} learner {getattr(state, 'learner_id', '?')}," + f" iteration {getattr(state, 'iteration', '?')}" + f" (metric={getattr(state, 'metric_value', '?')})" ) self._notify_state(wf) @@ -250,7 +247,7 @@ def on_iteration(state): wf.state = WorkflowState.FAILED wf.error = str(e) wf.end_time = time.time() - log.exception("[{self.sid}] Workflow {wf_id} failed") + log.exception(f"[{self.sid}] Workflow {wf_id} failed") finally: self._notify_state(wf) @@ -360,7 +357,7 @@ async def close(self) -> dict: timeout=5.0, ) except asyncio.TimeoutError: - log.warning("[%s] Timed out waiting for learner tasks to cancel", self.sid) + log.warning(f"[{self.sid}] Timed out waiting for learner tasks to cancel") self._learner_tasks.clear() # Shutdown engine @@ -372,7 +369,7 @@ async def close(self) -> dict: try: await asyncio.wait_for(self._engine.shutdown(), timeout=5.0) except asyncio.TimeoutError: - log.warning("[%s] Engine shutdown timed out, forcing exit", self.sid) + log.warning(f"[{self.sid}] Engine shutdown timed out, forcing exit") self._engine = None # asyncflow registers SIGINT/SIGTERM/SIGHUP via loop.add_signal_handler(), @@ -394,7 +391,7 @@ async def close(self) -> dict: if t is not current and t not in pre_shutdown_tasks and not t.done() ] if leftover: - log.debug("[%s] Draining %d leftover engine tasks", self.sid, len(leftover)) + log.debug(f"[{self.sid}] Draining {len(leftover)} leftover engine tasks") for t in leftover: t.cancel() await asyncio.gather(*leftover, return_exceptions=True) @@ -586,12 +583,12 @@ def __init__(self, app: FastAPI, instance_name: str = "rose"): # async def _on_shutdown(self) -> None: """Close all active sessions when the FastAPI app shuts down.""" - log.info("[rose] Shutting down %d active session(s)...", len(self._sessions)) + log.info(f"[rose] Shutting down {len(self._sessions)} active session(s)...") for session in list(self._sessions.values()): try: await asyncio.wait_for(session.close(), timeout=10.0) except (asyncio.TimeoutError, Exception) as exc: - log.warning("[rose] Session %s close error during shutdown: %s", session.sid, exc) + log.warning(f"[rose] Session {session.sid} close error during shutdown: {exc}") log.info("[rose] All sessions closed") # -------------------------------------------------------------------------- diff --git a/rose/service/manager.py b/rose/service/manager.py index 36e62c6b..1da0c672 100644 --- a/rose/service/manager.py +++ b/rose/service/manager.py @@ -115,16 +115,16 @@ def create_learner( task_func = cls._create_script_task_factory(cpath) if name == "simulation": - logger.info("Registering simulation task for workflow {wf_id}") + logger.info(f"Registering simulation task for workflow {wf_id}") learner.simulation_task(as_executable=as_executable)(task_func) elif name == "training": - logger.info("Registering training task for workflow {wf_id}") + logger.info(f"Registering training task for workflow {wf_id}") learner.training_task(as_executable=as_executable)(task_func) elif name == "active_learn": - logger.info("Registering active_learn task for workflow {wf_id}") + logger.info(f"Registering active_learn task for workflow {wf_id}") learner.active_learn_task(as_executable=as_executable)(task_func) elif name == "criterion": - logger.info("Registering criterion task for workflow {wf_id}") + logger.info(f"Registering criterion task for workflow {wf_id}") threshold = comp_def.get("threshold", 0.0) metric = comp_def.get("metric", "CUSTOM") learner.as_stop_criterion( @@ -193,7 +193,7 @@ async def initialize(self): backend = LocalExecutionBackend() self.engine = await WorkflowEngine.create(backend) - logger.info("Service initialized at {self.service_root}") + logger.info(f"Service initialized at {self.service_root}") async def _process_requests(self): """Pick up JSON request files from requests_dir and dispatch them.""" @@ -221,7 +221,7 @@ async def _process_requests(self): req_file.unlink() except Exception: - logger.exception("Error processing request {req_file}") + logger.exception(f"Error processing request {req_file}") try: req_file.unlink() except Exception: @@ -247,7 +247,7 @@ async def _handle_submit(self, req_id: str, payload: dict[str, Any]): self._learner_tasks[wf_id] = task except Exception as e: - logger.exception("Failed to submit workflow {wf_id}") + logger.exception(f"Failed to submit workflow {wf_id}") wf.state = WorkflowState.FAILED wf.error = str(e) self._update_registry() @@ -260,7 +260,7 @@ async def _handle_cancel(self, payload: dict[str, Any]): WorkflowState.INITIALIZING, WorkflowState.SUBMITTED, ): - logger.info("Canceling workflow {wf_id}") + logger.info(f"Canceling workflow {wf_id}") if wf.learner_instance: wf.learner_instance.stop() task = self._learner_tasks.get(wf_id) @@ -278,7 +278,7 @@ async def _run_learner( """Driver loop for a single workflow.""" wf.state = WorkflowState.RUNNING wf.start_time = asyncio.get_event_loop().time() - logger.info("Starting workflow {wf.wf_id} ({wf.workflow_file})") + logger.info(f"Starting workflow {wf.wf_id} ({wf.workflow_file})") self._update_registry() try: @@ -286,11 +286,9 @@ async def _run_learner( def on_iteration(state): wf.stats = state.to_dict() if hasattr(state, "to_dict") else {"result": str(state)} logger.info( - "Workflow %s - learner %s, iteration %s (metric=%s)", - wf.wf_id, - getattr(state, "learner_id", "?"), - getattr(state, "iteration", "?"), - getattr(state, "metric_value", "?"), + f"Workflow {wf.wf_id} - learner {getattr(state, 'learner_id', '?')}," + f" iteration {getattr(state, 'iteration', '?')}" + f" (metric={getattr(state, 'metric_value', '?')})" ) self._update_registry() @@ -298,16 +296,16 @@ def on_iteration(state): wf.learner_instance, wf_def, initial_l_config, on_iteration ) wf.state = WorkflowState.COMPLETED - logger.info("Workflow {wf.wf_id} completed successfully") + logger.info(f"Workflow {wf.wf_id} completed successfully") except asyncio.CancelledError: wf.state = WorkflowState.CANCELED - logger.info("Workflow {wf.wf_id} canceled") + logger.info(f"Workflow {wf.wf_id} canceled") except Exception as e: wf.state = WorkflowState.FAILED wf.error = str(e) - logger.exception("Workflow {wf.wf_id} failed") + logger.exception(f"Workflow {wf.wf_id} failed") finally: wf.end_time = asyncio.get_event_loop().time() @@ -339,13 +337,13 @@ async def shutdown(self): logger.info("Service Shutting Down...") if self.workflows: - logger.info("Stopping {len(self.workflows)} workflows") + logger.info(f"Stopping {len(self.workflows)} workflows") for wf in self.workflows.values(): if wf.learner_instance: wf.learner_instance.stop() if self._learner_tasks: - logger.info("Canceling {len(self._learner_tasks)} learner tasks") + logger.info(f"Canceling {len(self._learner_tasks)} learner tasks") lerner_tasks = list(self._learner_tasks.values()) for lerner_task in lerner_tasks: if not lerner_task.done(): From 7df816f4b3aee6bca032b5568d17565c8107d84b Mon Sep 17 00:00:00 2001 From: Andre Merzky Date: Tue, 31 Mar 2026 00:18:30 +0200 Subject: [PATCH 28/30] Fix RoseSession: update _notify -> _plugin._dispatch_notify radical.edge changed the notification pattern from a per-session _notify closure to a _plugin reference with _dispatch_notify(). Update all callsites in RoseSession to match. Co-Authored-By: Claude Sonnet 4.6 --- rose/service/api/rest.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/rose/service/api/rest.py b/rose/service/api/rest.py index df3ad52a..a16240a1 100644 --- a/rose/service/api/rest.py +++ b/rose/service/api/rest.py @@ -154,8 +154,8 @@ async def submit_workflow(self, workflow_file: str) -> dict: self._workflows[wf_id] = wf # Notify submission - if self._notify: - self._notify( + if self._plugin: + self._plugin._dispatch_notify( "workflow_state", {"wf_id": wf_id, "state": "SUBMITTED", "workflow_file": workflow_file}, ) @@ -202,8 +202,8 @@ def _on_done(fut): excerpt = next((line.strip() for line in raw.splitlines() if line.strip()), "")[ :120 ] - if self._notify: - self._notify( + if self._plugin: + self._plugin._dispatch_notify( "task_event", {"wf_id": wf_id, "task_id": tid, "ok": is_ok, "excerpt": excerpt}, ) @@ -258,10 +258,11 @@ def on_iteration(state): # def _notify_state(self, wf: Workflow): """Send workflow state notification.""" - if self._notify: - self._notify( + if self._plugin: + self._plugin._dispatch_notify( "workflow_state", - {"wf_id": wf.wf_id, "state": wf.state.value, "stats": wf.stats, "error": wf.error}, + {"wf_id": wf.wf_id, "state": wf.state.value, + "stats": wf.stats, "error": wf.error}, ) # -------------------------------------------------------------------------- From f87081e37a9186f5d505146ebb82a401fc8f1d25 Mon Sep 17 00:00:00 2001 From: Andre Merzky Date: Tue, 31 Mar 2026 09:12:18 +0200 Subject: [PATCH 29/30] Add notification coverage tests for RoseSession Four new tests in TestRoseSession: - test_submit_workflow_dispatches_submitted_notification: checks that submit_workflow fires workflow_state/SUBMITTED via _dispatch_notify - test_notify_state_calls_dispatch_notify: checks _notify_state payload - test_notify_state_no_plugin_does_not_raise: guards against AttributeError when _plugin is None (bare unit-test creation without a plugin) - test_task_event_dispatched_via_plugin: verifies the _on_done callback in _run_workflow fires a task_event notification Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_rose_plugin.py | 97 ++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/tests/unit/test_rose_plugin.py b/tests/unit/test_rose_plugin.py index 7e4ba958..ae479077 100644 --- a/tests/unit/test_rose_plugin.py +++ b/tests/unit/test_rose_plugin.py @@ -271,6 +271,103 @@ async def test_session_closed_check(self, rose_session): with pytest.raises(RuntimeError, match="session is closed"): await rose_session.list_workflows() + # ------------------------------------------------------------------ + # Notification (_dispatch_notify) tests + # ------------------------------------------------------------------ + + @pytest.mark.asyncio + async def test_submit_workflow_dispatches_submitted_notification( + self, rose_session, sample_workflow_yaml): + """submit_workflow fires a SUBMITTED workflow_state notification.""" + mock_plugin = MagicMock() + rose_session._plugin = mock_plugin + + with ( + patch.object(rose_session, "_ensure_engine", new_callable=AsyncMock), + patch.object(rose_session, "_run_workflow", new_callable=AsyncMock), + ): + rose_session._engine = Mock() + result = await rose_session.submit_workflow(sample_workflow_yaml) + + wf_id = result["wf_id"] + mock_plugin._dispatch_notify.assert_called_once_with( + "workflow_state", + {"wf_id": wf_id, "state": "SUBMITTED", "workflow_file": sample_workflow_yaml}, + ) + + @pytest.mark.asyncio + async def test_notify_state_calls_dispatch_notify(self, rose_session): + """_notify_state sends the current workflow state via _dispatch_notify.""" + mock_plugin = MagicMock() + rose_session._plugin = mock_plugin + + wf = Workflow(wf_id="wf.ns01", state=WorkflowState.RUNNING) + wf.stats = {"iteration": 3} + rose_session._workflows["wf.ns01"] = wf + + rose_session._notify_state(wf) + + mock_plugin._dispatch_notify.assert_called_once_with( + "workflow_state", + {"wf_id": "wf.ns01", "state": "RUNNING", "stats": {"iteration": 3}, "error": None}, + ) + + @pytest.mark.asyncio + async def test_notify_state_no_plugin_does_not_raise(self, rose_session): + """_notify_state is a no-op when _plugin is None (e.g. in bare unit tests).""" + wf = Workflow(wf_id="wf.nop", state=WorkflowState.SUBMITTED) + # _plugin is None by default — must not raise AttributeError + rose_session._notify_state(wf) + + @pytest.mark.asyncio + async def test_task_event_dispatched_via_plugin(self, rose_session): + """_run_workflow wraps learner tasks and fires task_event notifications.""" + mock_plugin = MagicMock() + rose_session._plugin = mock_plugin + + # Minimal fake learner whose _register_task calls the callback synchronously + import concurrent.futures + fut = concurrent.futures.Future() + fut.set_result("ok output") + + orig_calls = [] + + def fake_register(task_obj, deps=None): + orig_calls.append(task_obj) + return fut + + mock_learner = Mock() + mock_learner._register_task = fake_register + + with ( + patch.object(rose_session, "_ensure_engine", new_callable=AsyncMock), + patch("rose.service.api.rest.WorkflowLoader.load_yaml", return_value={}), + patch("rose.service.api.rest.WorkflowLoader.create_learner", + return_value=(mock_learner, {})), + patch("rose.service.api.rest.WorkflowLoader.run_learner", + new_callable=AsyncMock) as mock_run, + ): + rose_session._engine = Mock() + + # Trigger the patched _register_task wrapper by simulating run_learner + async def _side_effect(learner, wf_def, cfg, on_iter): + learner._register_task("dummy_task") + + mock_run.side_effect = _side_effect + + wf = Workflow(wf_id="wf.te01", state=WorkflowState.SUBMITTED) + rose_session._workflows["wf.te01"] = wf + await rose_session._run_workflow(wf) + + # The done-callback fires synchronously on a resolved Future, so + # _dispatch_notify should have been called with "task_event" + calls = [c for c in mock_plugin._dispatch_notify.call_args_list + if c[0][0] == "task_event"] + assert calls, "Expected at least one task_event notification" + payload = calls[0][0][1] + assert payload["wf_id"] == "wf.te01" + assert payload["ok"] is True + # ----------------------------------------------------------------------------- # RoseClient Tests From 53fb08653331af0b805f95423baee34fe4e4415b Mon Sep 17 00:00:00 2001 From: Andre Merzky Date: Tue, 31 Mar 2026 18:37:58 +0200 Subject: [PATCH 30/30] ruffing --- rose/service/api/rest.py | 3 +-- tests/unit/test_rose_plugin.py | 18 +++++++++++------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/rose/service/api/rest.py b/rose/service/api/rest.py index 567827d0..ed5001d4 100644 --- a/rose/service/api/rest.py +++ b/rose/service/api/rest.py @@ -260,8 +260,7 @@ def _notify_state(self, wf: Workflow): if self._plugin: self._plugin._dispatch_notify( "workflow_state", - {"wf_id": wf.wf_id, "state": wf.state.value, - "stats": wf.stats, "error": wf.error}, + {"wf_id": wf.wf_id, "state": wf.state.value, "stats": wf.stats, "error": wf.error}, ) # -------------------------------------------------------------------------- diff --git a/tests/unit/test_rose_plugin.py b/tests/unit/test_rose_plugin.py index ae479077..e775e069 100644 --- a/tests/unit/test_rose_plugin.py +++ b/tests/unit/test_rose_plugin.py @@ -277,7 +277,8 @@ async def test_session_closed_check(self, rose_session): @pytest.mark.asyncio async def test_submit_workflow_dispatches_submitted_notification( - self, rose_session, sample_workflow_yaml): + self, rose_session, sample_workflow_yaml + ): """submit_workflow fires a SUBMITTED workflow_state notification.""" mock_plugin = MagicMock() rose_session._plugin = mock_plugin @@ -327,6 +328,7 @@ async def test_task_event_dispatched_via_plugin(self, rose_session): # Minimal fake learner whose _register_task calls the callback synchronously import concurrent.futures + fut = concurrent.futures.Future() fut.set_result("ok output") @@ -342,10 +344,13 @@ def fake_register(task_obj, deps=None): with ( patch.object(rose_session, "_ensure_engine", new_callable=AsyncMock), patch("rose.service.api.rest.WorkflowLoader.load_yaml", return_value={}), - patch("rose.service.api.rest.WorkflowLoader.create_learner", - return_value=(mock_learner, {})), - patch("rose.service.api.rest.WorkflowLoader.run_learner", - new_callable=AsyncMock) as mock_run, + patch( + "rose.service.api.rest.WorkflowLoader.create_learner", + return_value=(mock_learner, {}), + ), + patch( + "rose.service.api.rest.WorkflowLoader.run_learner", new_callable=AsyncMock + ) as mock_run, ): rose_session._engine = Mock() @@ -361,8 +366,7 @@ async def _side_effect(learner, wf_def, cfg, on_iter): # The done-callback fires synchronously on a resolved Future, so # _dispatch_notify should have been called with "task_event" - calls = [c for c in mock_plugin._dispatch_notify.call_args_list - if c[0][0] == "task_event"] + calls = [c for c in mock_plugin._dispatch_notify.call_args_list if c[0][0] == "task_event"] assert calls, "Expected at least one task_event notification" payload = calls[0][0][1] assert payload["wf_id"] == "wf.te01"