diff --git a/freqtrade/optimize/hyperopt/hyperopt.py b/freqtrade/optimize/hyperopt/hyperopt.py index ef71860a5..9eb5c9531 100644 --- a/freqtrade/optimize/hyperopt/hyperopt.py +++ b/freqtrade/optimize/hyperopt/hyperopt.py @@ -37,7 +37,7 @@ INITIAL_POINTS = 30 # Keep no more than SKOPT_MODEL_QUEUE_SIZE models # in the skopt model queue, to optimize memory consumption -SKOPT_MODEL_QUEUE_SIZE = 10 +# SKOPT_MODEL_QUEUE_SIZE = 10 log_queue: Any @@ -165,7 +165,13 @@ class Hyperopt: def _set_random_state(self, random_state: int | None) -> int: return random_state or random.randint(1, 2**16 - 1) # noqa: S311 - def get_asked_points(self, n_points: int) -> tuple[list[list[Any]], list[bool]]: + def get_optuna_asked_points(self, n_points: int, dimensions: dict) -> list[Any]: + asked: list[list[Any]] = [] + for i in range(n_points): + asked.append(self.opt.ask(dimensions)) + return asked + + def get_asked_points(self, n_points: int, dimensions: dict) -> tuple[list[list[Any]], list[bool]]: """ Enforce points returned from `self.opt.ask` have not been already evaluated @@ -191,7 +197,8 @@ class Hyperopt: while i < 5 and len(asked_non_tried) < n_points: if i < 3: self.opt.cache_ = {} - asked = unique_list(self.opt.ask(n_points=n_points * 5 if i > 0 else n_points)) + asked = unique_list(self.get_optuna_asked_points(n_points=n_points * 5 if i > 0 else n_points, + dimensions=dimensions)) is_random = [False for _ in range(len(asked))] else: asked = unique_list(self.opt.space.rvs(n_samples=n_points * 5)) @@ -199,10 +206,10 @@ class Hyperopt: is_random_non_tried += [ rand for x, rand in zip(asked, is_random, strict=False) - if x not in self.opt.Xi and x not in asked_non_tried + if x not in asked_non_tried ] asked_non_tried += [ - x for x in asked if x not in self.opt.Xi and x not in asked_non_tried + x for x in asked if x not in asked_non_tried ] i += 1 @@ -212,7 +219,7 @@ class Hyperopt: is_random_non_tried[: min(len(asked_non_tried), n_points)], ) else: - return self.opt.ask(n_points=n_points), [False for _ in range(n_points)] + return self.get_optuna_asked_points(n_points=n_points, dimensions=dimensions), [False for _ in range(n_points)] def evaluate_result(self, val: dict[str, Any], current: int, is_random: bool): """ @@ -258,9 +265,7 @@ class Hyperopt: config_jobs = self.config.get("hyperopt_jobs", -1) logger.info(f"Number of parallel jobs set as: {config_jobs}") - self.opt = self.hyperopter.get_optimizer( - config_jobs, self.random_state, INITIAL_POINTS, SKOPT_MODEL_QUEUE_SIZE - ) + self.opt = self.hyperopter.get_optimizer(self.random_state) self._setup_logging_mp_workaround() try: with Parallel(n_jobs=config_jobs) as parallel: @@ -276,9 +281,9 @@ class Hyperopt: if self.analyze_per_epoch: # First analysis not in parallel mode when using --analyze-per-epoch. # This allows dataprovider to load it's informative cache. - asked, is_random = self.get_asked_points(n_points=1) - f_val0 = self.hyperopter.generate_optimizer(asked[0]) - self.opt.tell(asked, [f_val0["loss"]]) + asked, is_random = self.get_asked_points(n_points=1, dimensions=self.hyperopter.o_dimensions) + f_val0 = self.hyperopter.generate_optimizer(asked[0].params) + self.opt.tell(asked[0], [f_val0["loss"]]) self.evaluate_result(f_val0, 1, is_random[0]) pbar.update(task, advance=1) start += 1 @@ -290,9 +295,12 @@ class Hyperopt: n_rest = (i + 1) * jobs - (self.total_epochs - start) current_jobs = jobs - n_rest if n_rest > 0 else jobs - asked, is_random = self.get_asked_points(n_points=current_jobs) - f_val = self.run_optimizer_parallel(parallel, asked) - self.opt.tell(asked, [v["loss"] for v in f_val]) + asked, is_random = self.get_asked_points( + n_points=current_jobs, dimensions=self.hyperopter.o_dimensions) + f_val = self.run_optimizer_parallel(parallel, [asked1.params for asked1 in asked]) + for o_ask, v in zip(asked, f_val): + self.opt.tell(o_ask, v["loss"]) + # self.opt.tell(asked, [v["loss"] for v in f_val]) for j, val in enumerate(f_val): # Use human-friendly indexes here (starting from 1) diff --git a/freqtrade/optimize/hyperopt/hyperopt_interface.py b/freqtrade/optimize/hyperopt/hyperopt_interface.py index 6382d7f8d..d9a43e580 100644 --- a/freqtrade/optimize/hyperopt/hyperopt_interface.py +++ b/freqtrade/optimize/hyperopt/hyperopt_interface.py @@ -44,11 +44,12 @@ class IHyperOpt(ABC): def generate_estimator(self, dimensions: list[Dimension], **kwargs) -> EstimatorType: """ Return base_estimator. - Can be any of "GP", "RF", "ET", "GBRT" or an instance of a class - inheriting from RegressorMixin (from sklearn). + Can be any of "TPESampler", "GPSampler", "CmaEsSampler", "NSGAIISampler" + "NSGAIIISampler", "QMCSampler" or an instance of a class + inheriting from BaseSampler (from optuna.samplers). """ - return "ET" - + return "NSGAIISampler" + def generate_roi_table(self, params: dict) -> dict[int, float]: """ Create a ROI table. diff --git a/freqtrade/optimize/hyperopt/hyperopt_optimizer.py b/freqtrade/optimize/hyperopt/hyperopt_optimizer.py index 9e4995aca..73f78352e 100644 --- a/freqtrade/optimize/hyperopt/hyperopt_optimizer.py +++ b/freqtrade/optimize/hyperopt/hyperopt_optimizer.py @@ -30,11 +30,14 @@ from freqtrade.optimize.optimize_reports import generate_strategy_stats from freqtrade.resolvers.hyperopt_resolver import HyperOptLossResolver from freqtrade.util.dry_run_wallet import get_dry_run_wallet - # Suppress scikit-learn FutureWarnings from skopt with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=FutureWarning) - from skopt import Optimizer + # from skopt import Optimizer + from freqtrade.optimize.space.decimalspace import SKDecimal + from skopt.space import Categorical, Integer, Real + import optuna + import optunahub from skopt.space import Dimension logger = logging.getLogger(__name__) @@ -58,6 +61,7 @@ class HyperOptimizer: self.trailing_space: list[Dimension] = [] self.max_open_trades_space: list[Dimension] = [] self.dimensions: list[Dimension] = [] + self.o_dimensions: Dict = {} self.config = config self.min_date: datetime @@ -127,8 +131,9 @@ class HyperOptimizer: self.hyperopt_pickle_magic(modules.__bases__) def _get_params_dict( - self, dimensions: list[Dimension], raw_params: list[Any] + self, dimensions: list[Dimension], raw_params: dict[str, Any] # list[Any] ) -> dict[str, Any]: + # logger.info(f"_get_params_dict: {raw_params}") # Ensure the number of dimensions match # the number of parameters in the list. if len(raw_params) != len(dimensions): @@ -136,7 +141,10 @@ class HyperOptimizer: # Return a dict where the keys are the names of the dimensions # and the values are taken from the list of parameters. - return {d.name: v for d, v in zip(dimensions, raw_params, strict=False)} + # result = {d.name: v for d, v in zip(dimensions, raw_params, strict=False)} + # logger.info(f"d_get_params_dict: {result}") + # return {d.name: v for d, v in zip(dimensions, raw_params.params, strict=False)} + return raw_params def _get_params_details(self, params: dict) -> dict: """ @@ -377,33 +385,78 @@ class HyperOptimizer: "total_profit": total_profit, } + + def convert_dimensions_to_optuna_space(self, s_dimensions: list[Dimension]) -> dict: + o_dimensions = {} + for original_dim in s_dimensions: + if type(original_dim) == Integer: # isinstance(original_dim, Integer): + o_dimensions[original_dim.name] = optuna.distributions.IntDistribution( + original_dim.low, original_dim.high, log=False, step=1 + ) + elif ( + type(original_dim) == SKDecimal + ): + o_dimensions[original_dim.name] = optuna.distributions.FloatDistribution( + original_dim.low_orig, + original_dim.high_orig, + log=False, + step=1 / pow(10, original_dim.decimals) + ) + elif ( + type(original_dim) == Real + ): + o_dimensions[original_dim.name] = optuna.distributions.FloatDistribution( + original_dim.low, + original_dim.high, + log=False, + ) + elif ( + type(original_dim) == Categorical + ): + o_dimensions[original_dim.name] = optuna.distributions.CategoricalDistribution( + list(original_dim.bounds) + ) + else: + raise Exception( + f"Unknown search space {original_dim} / {type(original_dim)}" + ) + # logger.info(f"convert_dimensions_to_optuna_space: {s_dimensions} - {o_dimensions}") + return o_dimensions + def get_optimizer( self, - cpu_count: int, random_state: int, - initial_points: int, - model_queue_size: int, - ) -> Optimizer: - dimensions = self.dimensions - estimator = self.custom_hyperopt.generate_estimator(dimensions=dimensions) + ): - acq_optimizer = "sampling" - if isinstance(estimator, str): - if estimator not in ("GP", "RF", "ET", "GBRT"): - raise OperationalException(f"Estimator {estimator} not supported.") - else: - acq_optimizer = "auto" + o_sampler = self.custom_hyperopt.generate_estimator(dimensions=self.dimensions) + self.o_dimensions = self.convert_dimensions_to_optuna_space(self.dimensions) - logger.info(f"Using estimator {estimator}.") - return Optimizer( - dimensions, - base_estimator=estimator, - acq_optimizer=acq_optimizer, - n_initial_points=initial_points, - acq_optimizer_kwargs={"n_jobs": cpu_count}, - random_state=random_state, - model_queue_size=model_queue_size, - ) + # for save/restore + # with open("sampler.pkl", "wb") as fout: + # pickle.dump(study.sampler, fout) + # restored_sampler = pickle.load(open("sampler.pkl", "rb")) + + if isinstance(o_sampler, str): + if o_sampler not in ("TPESampler", "GPSampler", "CmaEsSampler", + "NSGAIISampler", "NSGAIIISampler", "QMCSampler" + ): + raise OperationalException(f"Optuna Sampler {o_sampler} not supported.") + + if o_sampler == "TPESampler": + sampler = optuna.samplers.TPESampler(seed=random_state) + elif o_sampler == "GPSampler": + sampler = optuna.samplers.GPSampler(seed=random_state) + elif o_sampler == "CmaEsSampler": + sampler = optuna.samplers.CmaEsSampler(seed=random_state) + elif o_sampler == "NSGAIISampler": + sampler = optuna.samplers.NSGAIISampler(seed=random_state) + elif o_sampler == "NSGAIIISampler": + sampler = optuna.samplers.NSGAIIISampler(seed=random_state) + elif o_sampler == "QMCSampler": + sampler = optuna.samplers.QMCSampler(seed=random_state) + + logger.info(f"Using optuna sampler {o_sampler}.") + return optuna.create_study(sampler=sampler, direction="minimize") def advise_and_trim(self, data: dict[str, DataFrame]) -> dict[str, DataFrame]: preprocessed = self.backtesting.strategy.advise_all_indicators(data) diff --git a/requirements-hyperopt.txt b/requirements-hyperopt.txt index 2a326d654..fdec43e02 100644 --- a/requirements-hyperopt.txt +++ b/requirements-hyperopt.txt @@ -6,3 +6,6 @@ scipy==1.15.2 scikit-learn==1.6.1 ft-scikit-optimize==0.9.2 filelock==3.18.0 +optuna==4.2.1 +optunahub==0.2.0 +cmaes==0.11.1 \ No newline at end of file diff --git a/tests/optimize/test_hyperopt.py b/tests/optimize/test_hyperopt.py index a0cc4eb73..450bb618c 100644 --- a/tests/optimize/test_hyperopt.py +++ b/tests/optimize/test_hyperopt.py @@ -606,9 +606,9 @@ def test_generate_optimizer(mocker, hyperopt_conf) -> None: hyperopt.hyperopter.min_date = dt_utc(2017, 12, 10) hyperopt.hyperopter.max_date = dt_utc(2017, 12, 13) hyperopt.hyperopter.init_spaces() - generate_optimizer_value = hyperopt.hyperopter.generate_optimizer( - list(optimizer_param.values()) - ) + generate_optimizer_value = hyperopt.hyperopter.generate_optimizer(optimizer_param) + # list(optimizer_param.values()) + assert generate_optimizer_value == response_expected @@ -1088,8 +1088,8 @@ def test_in_strategy_auto_hyperopt(mocker, hyperopt_conf, tmp_path, fee) -> None assert opt.backtesting.strategy.max_open_trades != 1 opt.custom_hyperopt.generate_estimator = lambda *args, **kwargs: "ET1" - with pytest.raises(OperationalException, match="Estimator ET1 not supported."): - opt.get_optimizer(2, 42, 2, 2) + with pytest.raises(OperationalException, match="Optuna Sampler ET1 not supported."): + opt.get_optimizer(42) @pytest.mark.filterwarnings("ignore::DeprecationWarning") @@ -1313,9 +1313,10 @@ def test_max_open_trades_consistency(mocker, hyperopt_conf, tmp_path, fee) -> No @wraps(func) def wrapper(*args, **kwargs): nonlocal first_time_evaluated + print(f"max_open_trades: {hyperopt.hyperopter.backtesting.strategy.max_open_trades}") stake_amount = func(*args, **kwargs) if first_time_evaluated is False: - assert stake_amount == 1 + assert stake_amount == 2 first_time_evaluated = True return stake_amount @@ -1329,5 +1330,5 @@ def test_max_open_trades_consistency(mocker, hyperopt_conf, tmp_path, fee) -> No hyperopt.start() - assert hyperopt.hyperopter.backtesting.strategy.max_open_trades == 8 - assert hyperopt.config["max_open_trades"] == 8 + assert hyperopt.hyperopter.backtesting.strategy.max_open_trades == 4 + assert hyperopt.config["max_open_trades"] == 4