Files
NexQuant/rdagent/scenarios/qlib/developer/factor_runner.py
T
Linlang b666397786 fix: fix combined_factors_df.pkl not loading in docker (#697)
* Fix combined_factors_df.pkl not loading in docker

* change combined factors file type

* change qlib commit id
2025-03-18 18:36:21 +08:00

165 lines
7.1 KiB
Python

import pickle
from pathlib import Path
from typing import List
import pandas as pd
from pandarallel import pandarallel
from rdagent.components.coder.CoSTEER.evaluators import CoSTEERMultiFeedback
from rdagent.core.conf import RD_AGENT_SETTINGS
from rdagent.core.utils import cache_with_pickle, multiprocessing_wrapper
pandarallel.initialize(verbose=1)
from rdagent.components.runner import CachedRunner
from rdagent.core.exception import FactorEmptyError
from rdagent.log import rdagent_logger as logger
from rdagent.scenarios.qlib.experiment.factor_experiment import QlibFactorExperiment
DIRNAME = Path(__file__).absolute().resolve().parent
DIRNAME_local = Path.cwd()
# class QlibFactorExpWorkspace:
# def prepare():
# # create a folder;
# # copy template
# # place data inside the folder `combined_factors`
# #
# def execute():
# de = DockerEnv()
# de.run(local_path=self.ws_path, entry="qrun conf.yaml")
# TODO: supporting multiprocessing and keep previous results
class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
"""
Docker run
Everything in a folder
- config.yaml
- price-volume data dumper
- `data.py` + Adaptor to Factor implementation
- results in `mlflow`
"""
def calculate_information_coefficient(
self, concat_feature: pd.DataFrame, SOTA_feature_column_size: int, new_feature_columns_size: int
) -> pd.DataFrame:
res = pd.Series(index=range(SOTA_feature_column_size * new_feature_columns_size))
for col1 in range(SOTA_feature_column_size):
for col2 in range(SOTA_feature_column_size, SOTA_feature_column_size + new_feature_columns_size):
res.loc[col1 * new_feature_columns_size + col2 - SOTA_feature_column_size] = concat_feature.iloc[
:, col1
].corr(concat_feature.iloc[:, col2])
return res
def deduplicate_new_factors(self, SOTA_feature: pd.DataFrame, new_feature: pd.DataFrame) -> pd.DataFrame:
# calculate the IC between each column of SOTA_feature and new_feature
# if the IC is larger than a threshold, remove the new_feature column
# return the new_feature
concat_feature = pd.concat([SOTA_feature, new_feature], axis=1)
IC_max = (
concat_feature.groupby("datetime")
.parallel_apply(
lambda x: self.calculate_information_coefficient(x, SOTA_feature.shape[1], new_feature.shape[1])
)
.mean()
)
IC_max.index = pd.MultiIndex.from_product([range(SOTA_feature.shape[1]), range(new_feature.shape[1])])
IC_max = IC_max.unstack().max(axis=0)
return new_feature.iloc[:, IC_max[IC_max < 0.99].index]
@cache_with_pickle(CachedRunner.get_cache_key, CachedRunner.assign_cached_result)
def develop(self, exp: QlibFactorExperiment) -> QlibFactorExperiment:
"""
Generate the experiment by processing and combining factor data,
then passing the combined data to Docker for backtest results.
"""
if exp.based_experiments and exp.based_experiments[-1].result is None:
exp.based_experiments[-1] = self.develop(exp.based_experiments[-1])
if exp.based_experiments:
SOTA_factor = None
if len(exp.based_experiments) > 1:
SOTA_factor = self.process_factor_data(exp.based_experiments)
# Process the new factors data
new_factors = self.process_factor_data(exp)
if new_factors.empty:
raise FactorEmptyError("No valid factor data found to merge.")
# Combine the SOTA factor and new factors if SOTA factor exists
if SOTA_factor is not None and not SOTA_factor.empty:
new_factors = self.deduplicate_new_factors(SOTA_factor, new_factors)
if new_factors.empty:
raise FactorEmptyError("No valid factor data found to merge.")
combined_factors = pd.concat([SOTA_factor, new_factors], axis=1).dropna()
else:
combined_factors = new_factors
# Sort and nest the combined factors under 'feature'
combined_factors = combined_factors.sort_index()
combined_factors = combined_factors.loc[:, ~combined_factors.columns.duplicated(keep="last")]
new_columns = pd.MultiIndex.from_product([["feature"], combined_factors.columns])
combined_factors.columns = new_columns
# Due to the rdagent and qlib docker image in the numpy version of the difference,
# the `combined_factors_df.pkl` file could not be loaded correctly in qlib dokcer,
# so we changed the file type of `combined_factors_df` from pkl to parquet.
target_path = exp.experiment_workspace.workspace_path / "combined_factors_df.parquet"
# Save the combined factors to the workspace
combined_factors.to_parquet(target_path, engine="pyarrow")
result = exp.experiment_workspace.execute(
qlib_config_name=f"conf.yaml" if len(exp.based_experiments) == 0 else "conf_combined.yaml"
)
exp.result = result
return exp
def process_factor_data(self, exp_or_list: List[QlibFactorExperiment] | QlibFactorExperiment) -> pd.DataFrame:
"""
Process and combine factor data from experiment implementations.
Args:
exp (ASpecificExp): The experiment containing factor data.
Returns:
pd.DataFrame: Combined factor data without NaN values.
"""
if isinstance(exp_or_list, QlibFactorExperiment):
exp_or_list = [exp_or_list]
factor_dfs = []
# Collect all exp's dataframes
for exp in exp_or_list:
if len(exp.sub_tasks) > 0:
# if it has no sub_tasks, the experiment is results from template project.
# otherwise, it is developed with designed task. So it should have feedback.
assert isinstance(exp.prop_dev_feedback, CoSTEERMultiFeedback)
# Iterate over sub-implementations and execute them to get each factor data
message_and_df_list = multiprocessing_wrapper(
[
(implementation.execute, ("All",))
for implementation, fb in zip(exp.sub_workspace_list, exp.prop_dev_feedback)
if implementation and fb
], # only execute successfully feedback
n=RD_AGENT_SETTINGS.multi_proc_n,
)
for message, df in message_and_df_list:
# Check if factor generation was successful
if df is not None and "datetime" in df.index.names:
time_diff = df.index.get_level_values("datetime").to_series().diff().dropna().unique()
if pd.Timedelta(minutes=1) not in time_diff:
factor_dfs.append(df)
# Combine all successful factor data
if factor_dfs:
return pd.concat(factor_dfs, axis=1)
else:
raise FactorEmptyError("No valid factor data found to merge.")