Files
NexQuant/rdagent/utils/env.py
T
Xu Yang d5a6a08210 Several update on the repo (see desc) (#76)
* ignore result csv file

* fix app scripts

* rename taskgenerator to developer and generate to develop

* fix a config bug in coder

* fix a small bug in factor coder evaluators

* remove a single logger in factor coder evaluators

* fix a small bug in model coder main.py

* rename Implementation to Workspace

* move the prepare the inject_code into FBWorkspace to align all the behavior

* fix a small bug in model feedback

* remove debug lines for multi processing and simplify evaluators multi proc

* add a copy function to workspace to freeze the workspace && add config prefix to speed up debugging

* make hypothesisgen a abc class

* use Qlib***Experiment

* fix a small bug

* rename Imp to Ws

* rename sub_implementations to sub_workspace_list

* fix a bug in feedback not presented as content in prompts

* move proposal pys to proposal folder

* reformat the folder

* align factor and model qlib workspace and use template to handle the workspace

* add a filter to evoagent to filter out false evo

* align multi_proc_n into RDAGENT seeting

* handle when runner gets empty experiment

* fix logger merge remaining problems

* fix black and isort automatically
2024-07-17 15:00:13 +08:00

227 lines
7.8 KiB
Python

"""
The motiviation of the utils is for environment management
Tries to create uniform environment for the agent to run;
- All the code and data is expected included in one folder
"""
import os
import subprocess
import sys
from abc import abstractmethod
from pathlib import Path
from typing import Dict, Generic, Optional, TypeVar
import docker
import docker.models
import docker.models.containers
from pydantic import BaseModel
from pydantic_settings import BaseSettings
from rdagent.log import rdagent_logger as logger
ASpecificBaseModel = TypeVar("ASpecificBaseModel", bound=BaseModel)
class Env(Generic[ASpecificBaseModel]):
"""
We use BaseModel as the setting due to the featurs it provides
- It provides base typing and checking featurs.
- loading and dumping the information will be easier: for example, we can use package like `pydantic-yaml`
"""
conf: ASpecificBaseModel # different env have different conf.
def __init__(self, conf: ASpecificBaseModel):
self.conf = conf
@abstractmethod
def prepare(self):
"""
Prepare for the environment based on it's configure
"""
@abstractmethod
def run(self, entry: str | None, local_path: str | None = None, env: dict | None = None) -> str:
"""
Run the folder under the environment.
Parameters
----------
entry : str | None
We may we the entry point when we run it.
For example, we may have different entries when we run and summarize the project.
local_path : str | None
the local path (to project, mainly for code) will be mounted into the docker
Here are some examples for a None local path
- for example, run docker for updating the data in the extra_volumes.
- simply run the image. The results are produced by output or network
env : dict | None
Run the code with your specific environment.
Returns
-------
the stdout
"""
## Local Environment -----
class LocalConf(BaseModel):
py_bin: str
default_entry: str
class LocalEnv(Env[LocalConf]):
"""
Sometimes local environment may be more convinient for testing
"""
def prepare(self):
if not (Path("~/.qlib/qlib_data/cn_data").expanduser().resolve().exists()):
self.run(
entry="python -m qlib.run.get_data qlib_data --target_dir ~/.qlib/qlib_data/cn_data --region cn",
)
else:
print("Data already exists. Download skipped.")
def run(self, entry: str | None = None, local_path: Optional[str] = None, env: dict | None = None) -> str:
if env is None:
env = {}
if entry is None:
entry = self.conf.default_entry
command = str(Path(self.conf.py_bin).joinpath(entry)).split(" ")
cwd = None
if local_path:
cwd = Path(local_path).resolve()
result = subprocess.run(command, cwd=cwd, env={**os.environ, **env}, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Error while running the command: {result.stderr}")
return result.stdout
## Docker Environment -----
class DockerConf(BaseSettings):
build_from_dockerfile: bool = False
dockerfile_folder_path: Optional[
Path
] = None # the path to the dockerfile optional path provided when build_from_dockerfile is False
image: str # the image you want to build
mount_path: str # the path in the docker image to mount the folder
default_entry: str # the entry point of the image
extra_volumes: dict | None = {}
# Sometime, we need maintain some extra data for the workspace.
# And the extra data may be shared and the downloading can be time consuming.
# So we just want to download it once.
network: str | None = "bridge" # the network mode for the docker
shm_size: str | None = None
class QlibDockerConf(DockerConf):
class Config:
env_prefix = "QLIB_DOCKER_" # Use QLIB_DOCKER_ as prefix for environment variables
build_from_dockerfile: bool = True
dockerfile_folder_path: Path = Path(__file__).parent.parent / "scenarios" / "qlib" / "docker"
image: str = "local_qlib:latest"
mount_path: str = "/workspace/qlib_workspace/"
default_entry: str = "qrun conf.yaml"
extra_volumes: dict = {Path("~/.qlib/").expanduser().resolve(): "/root/.qlib/"}
shm_size: str | None = "16g"
class DockerEnv(Env[DockerConf]):
# TODO: Save the output into a specific file
def prepare(self):
"""
Download image if it doesn't exist
"""
client = docker.from_env()
if self.conf.build_from_dockerfile and self.conf.dockerfile_folder_path.exists():
logger.info(f"Building the image from dockerfile: {self.conf.dockerfile_folder_path}")
image, logs = client.images.build(
path=str(self.conf.dockerfile_folder_path), tag=self.conf.image, network_mode=self.conf.network
)
logger.info(f"Finished building the image from dockerfile: {self.conf.dockerfile_folder_path}")
try:
client.images.get(self.conf.image)
except docker.errors.ImageNotFound:
client.images.pull(self.conf.image)
except docker.errors.APIError as e:
raise RuntimeError(f"Error while pulling the image: {e}")
def run(self, entry: str | None = None, local_path: str | None = None, env: dict | None = None):
if env is None:
env = {}
client = docker.from_env()
if entry is None:
entry = self.conf.default_entry
volumns = {}
if local_path is not None:
local_path = os.path.abspath(local_path)
volumns[local_path] = {"bind": self.conf.mount_path, "mode": "rw"}
if self.conf.extra_volumes is not None:
for lp, rp in self.conf.extra_volumes.items():
volumns[lp] = {"bind": rp, "mode": "rw"}
log_output = ""
try:
container: docker.models.containers.Container = client.containers.run(
image=self.conf.image,
command=entry,
volumes=volumns,
environment=env,
detach=True,
working_dir=self.conf.mount_path,
# auto_remove=True, # remove too fast might cause the logs not to be get
network=self.conf.network,
shm_size=self.conf.shm_size,
)
logs = container.logs(stream=True)
for log in logs:
decoded_log = log.strip().decode()
print(decoded_log)
log_output += decoded_log + "\n"
container.wait()
container.stop()
container.remove()
return log_output
except docker.errors.ContainerError as e:
raise RuntimeError(f"Error while running the container: {e}")
except docker.errors.ImageNotFound:
raise RuntimeError("Docker image not found.")
except docker.errors.APIError as e:
raise RuntimeError(f"Error while running the container: {e}")
class QTDockerEnv(DockerEnv):
"""Qlib Torch Docker"""
def __init__(self, conf: DockerConf = QlibDockerConf()):
super().__init__(conf)
def prepare(self):
"""
Download image & data if it doesn't exist
"""
super().prepare()
qlib_data_path = next(iter(self.conf.extra_volumes.keys()))
if not (Path(qlib_data_path) / "qlib_data" / "cn_data").exists():
logger.info("We are downloading!")
cmd = "python -m qlib.run.get_data qlib_data --target_dir ~/.qlib/qlib_data/cn_data --region cn --interval 1d --delete_old False"
self.run(entry=cmd)
else:
logger.info("Data already exists. Download skipped.")