From: Jérôme Benoit Date: Thu, 30 Jul 2026 21:41:32 +0000 (+0200) Subject: fix(quickadapter): validate Optuna numeric configuration (#190) X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=963b79e5239998b4481fd636604bb2fba1cc748e;p=freqai-strategies.git fix(quickadapter): validate Optuna numeric configuration (#190) * fix(quickadapter): validate Optuna numeric configuration * refactor(quickadapter): reuse shared numeric validator for Optuna config Route the Optuna numeric configuration validation through a new public Utils.require_numeric() helper that wraps the canonical _NumericValidator, instead of reimplementing bool/int/range checks inline. This removes the duplicated validation logic, harmonizes error messages with the rest of the module, and drops the unreachable math.isfinite() branch on space_fraction (non-finite values already fail the range check). Also align the README seed range notation (int [0, 4294967295]) with the existing int [2, 10000] style and the emitted error message. Behavior-preserving: raise/pass outcomes are identical to the previous implementation across the full option matrix, except unbounded integer options with values >= 2**64 (n_trials, timeout, n_startup_trials) are now rejected via the shared finiteness contract (values <= 2**63 unchanged). * fix(quickadapter): preserve numeric validation contracts Keep exact built-in type semantics when using the shared helper and treat arbitrary-precision Python integers as finite. * refactor(quickadapter): reuse shared boolean validator for Optuna config Route the Optuna boolean option validation through a new public Utils.require_bool() helper that wraps the canonical _BoolValidator, mirroring require_numeric(). This removes the last inline validation in _optuna_config so both boolean and numeric checks now share the framework validators, closing the residual duplication. Behavior-preserving: raise/pass outcomes are identical to the previous inline check across all boolean options (True/False accepted, every other type rejected). * docs(quickadapter): justify the int short-circuit in _is_finite_value The comment stated a Python language truism (integers are arbitrary-precision and finite) instead of explaining why the branch exists. Reword it to document the load-bearing rationale: np.isfinite raises TypeError/OverflowError on Python ints >= 2**64, so routing them through it would misclassify finite values as non-finite. Comment-only change; no behavior change. --- diff --git a/README.md b/README.md index 5407b91..3120346 100644 --- a/README.md +++ b/README.md @@ -139,13 +139,13 @@ docker compose up -d --build | freqai.optuna_hyperopt.warm_start | true | bool | Warm start HPO with previous best value(s). Persisted values are loaded and saved only in live and dry-run modes; non-live runs reuse only values produced earlier in the same run. | | freqai.optuna_hyperopt.n_startup_trials | 15 | int >= 0 | HPO startup trials. | | freqai.optuna_hyperopt.n_trials | 50 | int >= 1 | Maximum HPO trials. | -| freqai.optuna_hyperopt.n_jobs | 1 | int >= 1 | Parallel HPO workers. The effective value is capped at `max(1, CPU threads // 4)`; that cap is not the default. | +| freqai.optuna_hyperopt.n_jobs | 1 | int >= 1 | Parallel HPO workers. | | freqai.optuna_hyperopt.timeout | 7200 | int >= 0 | HPO wall-clock timeout in seconds. | | freqai.optuna_hyperopt.label_candles_step | 1 | int >= 1 | Step for Zigzag NATR period `label` search space. | | freqai.optuna_hyperopt.space_reduction | false | bool | Enable/disable `hp` search space reduction based on previous best parameters. | | freqai.optuna_hyperopt.space_fraction | 0.4 | float [0,1] | Fraction of the `hp` search space to use with `space_reduction`. Lower values create narrower search ranges around the best parameters. | | freqai.optuna_hyperopt.min_resource | 3 | int >= 1 | Minimum resource per [HyperbandPruner](https://optuna.readthedocs.io/en/stable/reference/generated/optuna.pruners.HyperbandPruner.html) rung. | -| freqai.optuna_hyperopt.seed | 1 | int >= 0 | HPO RNG seed used by the Optuna samplers and label-candle shuffling. | +| freqai.optuna_hyperopt.seed | 1 | int [0, 4294967295] | HPO RNG seed used by the Optuna samplers and label-candle shuffling. | | freqai.optuna_hyperopt.reset_label_study_on_schema_mismatch | true | bool | Reset a persisted `label` study when its selection schema is missing, invalid, or incompatible. `true` keeps the historical destructive reset, deleting the study before recreating it; `false` preserves its trials and stored metadata, permits caller-managed reuse in memory, and does not persist selected params until the schema is reconciled. Both fail closed: an inspection error, or (under `true`) a deletion error, aborts study creation. Has no effect when `continuous=true` or outside live/dry-run modes, where studies are always reset. | | freqai.optuna_hyperopt.vary_model_seed_by_trial | true | bool | Add `trial.number` to each regressor's configured model seed (or its default seed of `1`) during HPO. `true` samples model randomness across trials and preserves the historical behavior; `false` evaluates every trial and the final fit with the same model seed. This does not change `freqai.optuna_hyperopt.seed`. | diff --git a/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py b/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py index 66871b0..6f378c8 100644 --- a/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py +++ b/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py @@ -105,6 +105,8 @@ from Utils import ( migrate_config, optuna_load_best_params, optuna_save_best_params, + require_bool, + require_numeric, sanitize_and_renormalize, safe_distribution_fit, summarize_label_weight_support, @@ -449,6 +451,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel): OPTUNA_SPACE_REDUCTION_DEFAULT: Final[bool] = False OPTUNA_SPACE_FRACTION_DEFAULT: Final[float] = 0.4 OPTUNA_SEED_DEFAULT: Final[int] = 1 + _OPTUNA_SEED_MAX: Final[int] = 2**32 - 1 OPTUNA_RESET_LABEL_STUDY_ON_SCHEMA_MISMATCH_DEFAULT: Final[bool] = True OPTUNA_VARY_MODEL_SEED_BY_TRIAL_DEFAULT: Final[bool] = True @@ -461,6 +464,16 @@ class QuickAdapterRegressorV3(BaseRegressionModel): "vary_model_seed_by_trial", ) + _OPTUNA_INT_OPTION_BOUNDS: Final[dict[str, tuple[int, Optional[int]]]] = { + "n_jobs": (1, None), + "n_startup_trials": (0, None), + "n_trials": (1, None), + "timeout": (0, None), + "label_candles_step": (1, None), + "min_resource": (1, None), + "seed": (0, _OPTUNA_SEED_MAX), + } + _DATA_SPLIT_METHODS: Final[tuple[str, ...]] = ( "train_test_split", "timeseries_split", @@ -1337,12 +1350,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel): def _optuna_config(self) -> dict[str, Any]: optuna_default_config = { "enabled": False, - "n_jobs": min( - self.config.get("freqai", {}) - .get("optuna_hyperopt", {}) - .get("n_jobs", QuickAdapterRegressorV3.OPTUNA_N_JOBS_DEFAULT), - max(int(self.max_system_threads / 4), 1), - ), + "n_jobs": QuickAdapterRegressorV3.OPTUNA_N_JOBS_DEFAULT, "sampler": QuickAdapterRegressorV3._OPTUNA_HPO_SAMPLERS.tpe, "storage": QuickAdapterRegressorV3._STORAGE_FILE, "continuous": True, @@ -1369,11 +1377,30 @@ class QuickAdapterRegressorV3(BaseRegressionModel): **optuna_hyperopt, } for option in QuickAdapterRegressorV3._OPTUNA_BOOL_OPTIONS: - if not isinstance(optuna_config[option], bool): - raise ValueError( - f"freqai.optuna_hyperopt.{option} must be a boolean " - f"(got {type(optuna_config[option]).__name__})" - ) + require_bool( + optuna_config[option], + option, + context="freqai.optuna_hyperopt", + ) + for option, ( + minimum, + maximum, + ) in QuickAdapterRegressorV3._OPTUNA_INT_OPTION_BOUNDS.items(): + require_numeric( + optuna_config[option], + option, + context="freqai.optuna_hyperopt", + minimum=minimum, + maximum=maximum, + require_int=True, + ) + require_numeric( + optuna_config["space_fraction"], + "space_fraction", + context="freqai.optuna_hyperopt", + minimum=0, + maximum=1, + ) return optuna_config @property diff --git a/quickadapter/user_data/strategies/Utils.py b/quickadapter/user_data/strategies/Utils.py index b1b6fda..7f8815d 100644 --- a/quickadapter/user_data/strategies/Utils.py +++ b/quickadapter/user_data/strategies/Utils.py @@ -323,9 +323,13 @@ def safe_log_ratio( def _is_finite_value(value: Any) -> bool: - # ``np.isfinite`` raises/returns non-scalar on inputs it cannot reduce to a - # single finite float64 (Python ints >= 2**64 -> ``TypeError``/``OverflowError``; - # array-likes -> a ``ValueError`` on ``bool()``); treat all of those as non-finite. + # Short-circuit Python ints (always finite): ``np.isfinite`` raises on ints + # >= 2**64 (``TypeError``/``OverflowError``), so routing them through it + # would misclassify finite values as non-finite. ``np.isfinite`` also + # raises/returns non-scalar on array-likes (``ValueError`` on ``bool()``); + # treat those remaining failures as non-finite. + if isinstance(value, int): + return True try: return bool(np.isfinite(value)) except (TypeError, OverflowError, ValueError): @@ -489,6 +493,42 @@ def _validate_params( return result +def require_numeric( + value: Any, + name: str, + *, + context: str, + minimum: float | None = None, + maximum: float | None = None, + min_exclusive: bool = False, + max_exclusive: bool = False, + require_int: bool = False, +) -> int | float: + """Return an exact built-in numeric value that satisfies the requested bounds.""" + validator = _NumericValidator( + min_value=minimum, + max_value=maximum, + min_exclusive=min_exclusive, + max_exclusive=max_exclusive, + require_int=require_int, + ) + accepted_types = (int,) if require_int else (int, float) + if type(value) not in accepted_types or not validator(value): + raise ValueError( + f"Invalid {context}.{name} value {value!r}: {validator.message(name)}" + ) + return value + + +def require_bool(value: Any, name: str, *, context: str) -> bool: + validator = _BoolValidator() + if not validator(value): + raise ValueError( + f"Invalid {context}.{name} value {value!r}: {validator.message(name)}" + ) + return value + + def validate_range( min_val: float | int, max_val: float | int,