warning -> info for low cost partial config (#231)

* warning -> info for low cost partial config
#195, #110

* when n_estimators < 0, use trained_estimator's

* log debug info

* test random seed

* remove "objective"; avoid ZeroDivisionError

* hp config to estimator params

* check type of searcher

* default n_jobs

* try import

* Update searchalgo_auto.py

* CLASSIFICATION

* auto_augment flag

* min_sample_size

* make catboost optional
This commit is contained in:
Chi Wang 2021-10-08 16:09:43 -07:00 committed by GitHub
parent a99e939404
commit f48ca2618f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1938 additions and 1859 deletions

View File

@ -103,7 +103,7 @@ print(automl.model)
```python
from flaml import AutoML
from sklearn.datasets import load_boston
from sklearn.datasets import fetch_california_housing
# Initialize an AutoML instance
automl = AutoML()
# Specify automl goal and constraint
@ -113,7 +113,7 @@ automl_settings = {
"task": 'regression',
"log_file_name": "test/boston.log",
}
X_train, y_train = load_boston(return_X_y=True)
X_train, y_train = fetch_california_housing(return_X_y=True)
# Train with labeled input data
automl.fit(X_train=X_train, y_train=y_train,
**automl_settings)

View File

@ -36,7 +36,7 @@ from .config import (
N_SPLITS,
SAMPLE_MULTIPLY_FACTOR,
)
from .data import concat
from .data import concat, CLASSIFICATION
from . import tune
from .training_log import training_log_reader, training_log_writer
@ -619,7 +619,8 @@ class AutoML:
if issparse(X_train_all):
X_train_all = X_train_all.tocsr()
if (
self._state.task in ("binary", "multi")
self._state.task in CLASSIFICATION
and self._auto_augment
and self._state.fit_kwargs.get("sample_weight") is None
and self._split_type not in ["time", "group"]
):
@ -725,7 +726,7 @@ class AutoML:
y_train, y_val = y_train_all[train_idx], y_train_all[val_idx]
self._state.groups = self._state.groups_all[train_idx]
self._state.groups_val = self._state.groups_all[val_idx]
elif self._state.task in ("binary", "multi"):
elif self._state.task in CLASSIFICATION:
# for classification, make sure the labels are complete in both
# training and validation data
label_set, first = np.unique(y_train_all, return_index=True)
@ -904,10 +905,11 @@ class AutoML:
n_splits=N_SPLITS,
split_type=None,
groups=None,
n_jobs=1,
n_jobs=-1,
train_best=True,
train_full=False,
record_id=-1,
auto_augment=True,
**fit_kwargs,
):
"""Retrain from log file
@ -943,7 +945,8 @@ class AutoML:
groups: None or array-like | Group labels (with matching length to
y_train) or groups counts (with sum equal to length of y_train)
for training data.
n_jobs: An integer of the number of threads for training.
n_jobs: An integer of the number of threads for training. Use all
available resources when n_jobs == -1.
train_best: A boolean of whether to train the best config in the
time budget; if false, train the last config in the budget.
train_full: A boolean of whether to train on the full data. If true,
@ -952,6 +955,8 @@ class AutoML:
be retrained. By default `record_id = -1` which means this will be
ignored. `record_id = 0` corresponds to the first trial, and
when `record_id >= 0`, `time_budget` will be ignored.
auto_augment: boolean, default=True | Whether to automatically
augment rare classes.
**fit_kwargs: Other key word arguments to pass to fit() function of
the searched learners, such as sample_weight.
"""
@ -1018,6 +1023,7 @@ class AutoML:
elif eval_method == "auto":
eval_method = self._decide_eval_method(time_budget)
self.modelcount = 0
self._auto_augment = auto_augment
self._prepare_data(eval_method, split_ratio, n_splits)
self._state.time_budget = None
self._state.n_jobs = n_jobs
@ -1032,7 +1038,7 @@ class AutoML:
self._state.task = get_classification_objective(
len(np.unique(self._y_train_all))
)
if self._state.task in ("binary", "multi"):
if self._state.task in CLASSIFICATION:
assert split_type in [None, "stratified", "uniform", "time", "group"]
self._split_type = (
split_type or self._state.groups is None and "stratified" or "group"
@ -1191,7 +1197,7 @@ class AutoML:
Returns:
A float for the minimal sample size or None
"""
return MIN_SAMPLE_TRAIN if self._sample else None
return self._min_sample_size if self._sample else None
@property
def max_resource(self) -> Optional[float]:
@ -1282,7 +1288,7 @@ class AutoML:
sample_weight_val=None,
groups_val=None,
groups=None,
verbose=1,
verbose=3,
retrain_full=True,
split_type=None,
learner_selector="sample",
@ -1291,8 +1297,10 @@ class AutoML:
seed=None,
n_concurrent_trials=1,
keep_search_state=False,
append_log=False,
early_stop=False,
append_log=False,
auto_augment=True,
min_sample_size=MIN_SAMPLE_TRAIN,
**fit_kwargs,
):
"""Find a model for a given task
@ -1375,7 +1383,7 @@ class AutoML:
groups: None or array-like | Group labels (with matching length to
y_train) or groups counts (with sum equal to length of y_train)
for training data.
verbose: int, default=1 | Controls the verbosity, higher means more
verbose: int, default=3 | Controls the verbosity, higher means more
messages.
retrain_full: bool or str, default=True | whether to retrain the
selected model on the full training data when using holdout.
@ -1412,8 +1420,12 @@ class AutoML:
saving.
early_stop: boolean, default=False | Whether to stop early if the
search is considered to converge.
append_log: boolean, default=False | whetehr to directly append the log
append_log: boolean, default=False | Whetehr to directly append the log
records to the input log file if it exists.
auto_augment: boolean, default=True | Whether to automatically
augment rare classes.
min_sample_size: int, default=MIN_SAMPLE_TRAIN | the minimal sample
size when sample=True.
**fit_kwargs: Other key word arguments to pass to fit() function of
the searched learners, such as sample_weight. Include period as
a key word argument for 'forecast' task.
@ -1435,8 +1447,8 @@ class AutoML:
self._learner_selector = learner_selector
old_level = logger.getEffectiveLevel()
self.verbose = verbose
if verbose == 0:
logger.setLevel(logging.WARNING)
# if verbose == 0:
logger.setLevel(50 - verbose * 10)
if (not mlflow or not mlflow.active_run()) and not logger.handlers:
# Add the console handler.
_ch = logging.StreamHandler()
@ -1457,12 +1469,14 @@ class AutoML:
and (eval_method == "holdout" and self._state.X_val is None)
or (eval_method == "cv")
)
self._auto_augment = auto_augment
self._min_sample_size = min_sample_size
self._prepare_data(eval_method, split_ratio, n_splits)
self._sample = (
sample
and task != "rank"
and eval_method != "cv"
and (MIN_SAMPLE_TRAIN * SAMPLE_MULTIPLY_FACTOR < self._state.data_size)
and (self._min_sample_size * SAMPLE_MULTIPLY_FACTOR < self._state.data_size)
)
if "auto" == metric:
if "binary" in self._state.task:
@ -1584,8 +1598,8 @@ class AutoML:
for state in self._search_states.values():
if state.trained_estimator:
del state.trained_estimator
if verbose == 0:
logger.setLevel(old_level)
# if verbose == 0:
logger.setLevel(old_level)
def _search_parallel(self):
try:
@ -1631,6 +1645,8 @@ class AutoML:
points_to_evaluate=points_to_evaluate,
)
else:
self._state.time_from_start = time.time() - self._start_time_flag
time_left = self._state.time_budget - self._state.time_from_start
search_alg = SearchAlgo(
metric="val_loss",
space=space,
@ -1645,13 +1661,9 @@ class AutoML:
],
metric_constraints=self.metric_constraints,
seed=self._seed,
time_budget_s=time_left,
)
search_alg = ConcurrencyLimiter(search_alg, self._n_concurrent_trials)
self._state.time_from_start = time.time() - self._start_time_flag
time_left = self._state.time_budget - self._state.time_from_start
search_alg.set_search_properties(
None, None, config={"time_budget_s": time_left}
)
resources_per_trial = (
{"cpu": self._state.n_jobs} if self._state.n_jobs > 1 else None
)
@ -1782,7 +1794,7 @@ class AutoML:
search_space = search_state.search_space
if self._sample:
prune_attr = "FLAML_sample_size"
min_resource = MIN_SAMPLE_TRAIN
min_resource = self._min_sample_size
max_resource = self._state.data_size
else:
prune_attr = min_resource = max_resource = None
@ -1840,10 +1852,10 @@ class AutoML:
else:
search_space = None
if self._hpo_method in ("bs", "cfo", "cfocat"):
search_state.search_alg.set_search_properties(
search_state.search_alg.searcher.set_search_properties(
metric=None,
mode=None,
config={
setting={
"metric_target": self._state.best_loss,
},
)
@ -1852,7 +1864,7 @@ class AutoML:
search_state.training_function,
search_alg=search_state.search_alg,
time_budget_s=min(budget_left, self._state.train_time_limit),
verbose=max(self.verbose - 1, 0),
verbose=max(self.verbose - 3, 0),
use_ray=False,
)
time_used = time.time() - start_run_time
@ -2077,7 +2089,7 @@ class AutoML:
logger.info(estimators)
if len(estimators) <= 1:
return
if self._state.task in ("binary", "multi"):
if self._state.task in CLASSIFICATION:
from sklearn.ensemble import StackingClassifier as Stacker
else:
from sklearn.ensemble import StackingRegressor as Stacker
@ -2184,7 +2196,7 @@ class AutoML:
speed = delta_loss / delta_time
if speed:
estimated_cost = max(2 * gap / speed, estimated_cost)
estimated_cost == estimated_cost or 1e-10
estimated_cost == estimated_cost or 1e-9
inv.append(1 / estimated_cost)
else:
estimated_cost = self._eci[i]

View File

@ -1,15 +1,18 @@
"""!
* Copyright (c) 2020-2021 Microsoft Corporation. All rights reserved.
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
"""
import numpy as np
from scipy.sparse import vstack, issparse
import pandas as pd
from .training_log import training_log_reader
from datetime import datetime
CLASSIFICATION = ("binary", "multi", "classification")
def load_openml_dataset(
dataset_id, data_dir=None, random_state=0, dataset_format="dataframe"
@ -300,11 +303,7 @@ class DataTransformer:
)
self._drop = drop
if task in (
"binary",
"multi",
"classification",
) or not pd.api.types.is_numeric_dtype(y):
if task in CLASSIFICATION or not pd.api.types.is_numeric_dtype(y):
from sklearn.preprocessing import LabelEncoder
self.label_transformer = LabelEncoder()

View File

@ -33,7 +33,7 @@ from .model import (
ARIMA,
SARIMAX,
)
from .data import group_counts
from .data import CLASSIFICATION, group_counts
import logging
@ -301,7 +301,7 @@ def evaluate_model_CV(
valid_fold_num = total_fold_num = 0
n = kf.get_n_splits()
X_train_split, y_train_split = X_train_all, y_train_all
if task in ("binary", "multi"):
if task in CLASSIFICATION:
labels = np.unique(y_train_all)
else:
labels = None

View File

@ -13,11 +13,11 @@ from lightgbm import LGBMClassifier, LGBMRegressor, LGBMRanker
from scipy.sparse import issparse
import pandas as pd
from . import tune
from .data import group_counts
from .data import group_counts, CLASSIFICATION
import logging
logger = logging.getLogger(__name__)
logger = logging.getLogger("flaml.automl")
class BaseEstimator:
@ -30,24 +30,23 @@ class BaseEstimator:
for both regression and classification
"""
def __init__(self, task="binary", **params):
def __init__(self, task="binary", **config):
"""Constructor
Args:
task: A string of the task type, one of
'binary', 'multi', 'regression', 'rank', 'forecast'
n_jobs: An integer of the number of parallel threads
params: A dictionary of the hyperparameter names and values
config: A dictionary containing the hyperparameter names
and 'n_jobs' as keys. n_jobs is the number of parallel threads.
"""
self.params = params
self.params = self.config2params(config)
self.estimator_class = self._model = None
self._task = task
if "_estimator_type" in params:
self._estimator_type = params["_estimator_type"]
del self.params["_estimator_type"]
if "_estimator_type" in config:
self._estimator_type = self.params.pop("_estimator_type")
else:
self._estimator_type = (
"classifier" if task in ("binary", "multi") else "regressor"
"classifier" if task in CLASSIFICATION else "regressor"
)
def get_params(self, deep=False):
@ -83,8 +82,9 @@ class BaseEstimator:
current_time = time.time()
if "groups" in kwargs:
kwargs = kwargs.copy()
groups = kwargs.pop("groups")
if self._task == "rank":
kwargs["group"] = group_counts(kwargs["groups"])
kwargs["group"] = group_counts(groups)
# groups_val = kwargs.get('groups_val')
# if groups_val is not None:
# kwargs['eval_group'] = [group_counts(groups_val)]
@ -92,10 +92,13 @@ class BaseEstimator:
# (kwargs['X_val'], kwargs['y_val'])]
# kwargs['verbose'] = False
# del kwargs['groups_val'], kwargs['X_val'], kwargs['y_val']
del kwargs["groups"]
X_train = self._preprocess(X_train)
model = self.estimator_class(**self.params)
if logger.level == logging.DEBUG:
logger.debug(f"flaml.model - {model} fit started")
model.fit(X_train, y_train, **kwargs)
if logger.level == logging.DEBUG:
logger.debug(f"flaml.model - {model} fit finished")
train_time = time.time() - current_time
self._model = model
return train_time
@ -143,9 +146,8 @@ class BaseEstimator:
Each element at (i,j) is the probability for instance i to be in
class j
"""
assert self._task in (
"binary",
"multi",
assert (
self._task in CLASSIFICATION
), "predict_prob() only for classification task."
X_test = self._preprocess(X_test)
return self._model.predict_proba(X_test)
@ -160,9 +162,10 @@ class BaseEstimator:
Returns:
A dictionary of the search space.
Each key is the name of a hyperparameter, and value is a dict with
its domain and init_value (optional), cat_hp_cost (optional)
its domain (required) and low_cost_init_value, init_value,
cat_hp_cost (if applicable).
e.g.,
{'domain': tune.randint(lower=1, upper=10), 'init_value': 1}
{'domain': tune.randint(lower=1, upper=10), 'init_value': 1}.
"""
return {}
@ -171,11 +174,11 @@ class BaseEstimator:
"""[optional method] memory size of the estimator in bytes
Args:
config - the dict of the hyperparameter config
config - A dict of the hyperparameter config.
Returns:
A float of the memory size required by the estimator to train the
given config
given config.
"""
return 1.0
@ -189,10 +192,21 @@ class BaseEstimator:
"""[optional method] initialize the class"""
pass
def config2params(self, config: dict) -> dict:
"""[optional method] config dict to params dict
Args:
config - A dict of the hyperparameter config.
Returns:
A dict that will be passed to self.estimator_class's constructor.
"""
return config.copy()
class SKLearnEstimator(BaseEstimator):
def __init__(self, task="binary", **params):
super().__init__(task, **params)
def __init__(self, task="binary", **config):
super().__init__(task, **config)
def _preprocess(self, X):
if isinstance(X, pd.DataFrame):
@ -255,39 +269,22 @@ class LGBMEstimator(BaseEstimator):
},
}
def config2params(cls, config: dict) -> dict:
params = config.copy()
if "log_max_bin" in params:
params["max_bin"] = (1 << params.pop("log_max_bin")) - 1
return params
@classmethod
def size(cls, config):
num_leaves = int(round(config.get("num_leaves") or config["max_leaves"]))
n_estimators = int(round(config["n_estimators"]))
return (num_leaves * 3 + (num_leaves - 1) * 4 + 1.0) * n_estimators * 8
def __init__(self, task="binary", log_max_bin=8, **params):
super().__init__(task, **params)
if "objective" not in self.params:
# Default: regression for LGBMRegressor,
# binary or multiclass for LGBMClassifier
objective = "regression"
if "binary" in task:
objective = "binary"
elif "multi" in task:
objective = "multiclass"
elif "rank" == task:
objective = "lambdarank"
self.params["objective"] = objective
if "n_estimators" in self.params:
self.params["n_estimators"] = int(round(self.params["n_estimators"]))
if "num_leaves" in self.params:
self.params["num_leaves"] = int(round(self.params["num_leaves"]))
if "min_child_samples" in self.params:
self.params["min_child_samples"] = int(
round(self.params["min_child_samples"])
)
if "max_bin" not in self.params:
self.params["max_bin"] = 1 << int(round(log_max_bin)) - 1
def __init__(self, task="binary", **config):
super().__init__(task, **config)
if "verbose" not in self.params:
self.params["verbose"] = -1
# if "subsample_freq" not in self.params:
# self.params['subsample_freq'] = 1
if "regression" == task:
self.estimator_class = LGBMRegressor
elif "rank" == task:
@ -355,7 +352,7 @@ class LGBMEstimator(BaseEstimator):
if self.params["n_estimators"] > 0:
self._fit(X_train, y_train, **kwargs)
else:
self.params["n_estimators"] = n_iter
self.params["n_estimators"] = self._model.n_estimators
train_time = time.time() - start_time
return train_time
@ -415,56 +412,30 @@ class XGBoostEstimator(SKLearnEstimator):
def cost_relative2lgbm(cls):
return 1.6
def config2params(cls, config: dict) -> dict:
params = config.copy()
params["max_depth"] = params.get("max_depth", 0)
params["grow_policy"] = params.get("grow_policy", "lossguide")
params["booster"] = params.get("booster", "gbtree")
params["use_label_encoder"] = params.get("use_label_encoder", False)
params["tree_method"] = params.get("tree_method", "hist")
if "n_jobs" in config:
params["nthread"] = params.pop("n_jobs")
return params
def __init__(
self,
task="regression",
all_thread=False,
n_jobs=1,
n_estimators=4,
max_leaves=4,
subsample=1.0,
min_child_weight=1,
learning_rate=0.1,
reg_lambda=1.0,
reg_alpha=0.0,
colsample_bylevel=1.0,
colsample_bytree=1.0,
tree_method="auto",
**params,
**config,
):
super().__init__(task, **params)
self._n_estimators = int(round(n_estimators))
self.params.update(
{
"max_leaves": int(round(max_leaves)),
"max_depth": params.get("max_depth", 0),
"grow_policy": params.get("grow_policy", "lossguide"),
"tree_method": tree_method,
"verbosity": params.get("verbosity", 0),
"nthread": n_jobs,
"learning_rate": float(learning_rate),
"subsample": float(subsample),
"reg_alpha": float(reg_alpha),
"reg_lambda": float(reg_lambda),
"min_child_weight": float(min_child_weight),
"booster": params.get("booster", "gbtree"),
"colsample_bylevel": float(colsample_bylevel),
"colsample_bytree": float(colsample_bytree),
"objective": params.get("objective"),
}
)
if all_thread:
del self.params["nthread"]
def get_params(self, deep=False):
params = super().get_params()
params["n_jobs"] = params["nthread"]
return params
super().__init__(task, **config)
self.params["verbosity"] = 0
def fit(self, X_train, y_train, budget=None, **kwargs):
start_time = time.time()
if not issparse(X_train):
self.params["tree_method"] = "hist"
if issparse(X_train):
self.params["tree_method"] = "auto"
else:
X_train = self._preprocess(X_train)
if "sample_weight" in kwargs:
dtrain = xgb.DMatrix(X_train, label=y_train, weight=kwargs["sample_weight"])
@ -478,8 +449,10 @@ class XGBoostEstimator(SKLearnEstimator):
obj = objective
if "objective" in self.params:
del self.params["objective"]
self._model = xgb.train(self.params, dtrain, self._n_estimators, obj=obj)
_n_estimators = self.params.pop("n_estimators")
self._model = xgb.train(self.params, dtrain, _n_estimators, obj=obj)
self.params["objective"] = objective
self.params["n_estimators"] = _n_estimators
del dtrain
train_time = time.time() - start_time
return train_time
@ -502,54 +475,29 @@ class XGBoostSklearnEstimator(SKLearnEstimator, LGBMEstimator):
def cost_relative2lgbm(cls):
return XGBoostEstimator.cost_relative2lgbm()
def config2params(cls, config: dict) -> dict:
params = config.copy()
params["max_depth"] = 0
params["grow_policy"] = params.get("grow_policy", "lossguide")
params["booster"] = params.get("booster", "gbtree")
params["use_label_encoder"] = params.get("use_label_encoder", False)
params["tree_method"] = params.get("tree_method", "hist")
return params
def __init__(
self,
task="binary",
n_jobs=1,
n_estimators=4,
max_leaves=4,
subsample=1.0,
min_child_weight=1,
learning_rate=0.1,
reg_lambda=1.0,
reg_alpha=0.0,
colsample_bylevel=1.0,
colsample_bytree=1.0,
tree_method="hist",
**params,
**config,
):
super().__init__(task, **params)
del self.params["objective"]
del self.params["max_bin"]
super().__init__(task, **config)
del self.params["verbose"]
self.params.update(
{
"n_estimators": int(round(n_estimators)),
"max_leaves": int(round(max_leaves)),
"max_depth": 0,
"grow_policy": params.get("grow_policy", "lossguide"),
"tree_method": tree_method,
"n_jobs": n_jobs,
"verbosity": 0,
"learning_rate": float(learning_rate),
"subsample": float(subsample),
"reg_alpha": float(reg_alpha),
"reg_lambda": float(reg_lambda),
"min_child_weight": float(min_child_weight),
"booster": params.get("booster", "gbtree"),
"colsample_bylevel": float(colsample_bylevel),
"colsample_bytree": float(colsample_bytree),
"use_label_encoder": params.get("use_label_encoder", False),
}
)
self.params["verbosity"] = 0
self.estimator_class = xgb.XGBRegressor
if "rank" == task:
self.estimator_class = xgb.XGBRanker
elif task in ("binary", "multi"):
elif task in CLASSIFICATION:
self.estimator_class = xgb.XGBClassifier
self._time_per_iter = None
self._train_size = 0
def fit(self, X_train, y_train, budget=None, **kwargs):
if issparse(X_train):
@ -578,7 +526,7 @@ class RandomForestEstimator(SKLearnEstimator, LGBMEstimator):
"low_cost_init_value": 4,
},
}
if task in ("binary", "multi"):
if task in CLASSIFICATION:
space["criterion"] = {
"domain": tune.choice(["gini", "entropy"]),
# 'init_value': 'gini',
@ -589,36 +537,24 @@ class RandomForestEstimator(SKLearnEstimator, LGBMEstimator):
def cost_relative2lgbm(cls):
return 2.0
def config2params(cls, config: dict) -> dict:
params = config.copy()
if "max_leaves" in params:
params["max_leaf_nodes"] = params.get(
"max_leaf_nodes", params.pop("max_leaves")
)
return params
def __init__(
self,
task="binary",
n_jobs=1,
n_estimators=4,
max_features=1.0,
criterion="gini",
max_leaves=4,
**params,
):
super().__init__(task, **params)
del self.params["objective"]
del self.params["max_bin"]
self.params.update(
{
"n_estimators": int(round(n_estimators)),
"n_jobs": n_jobs,
"verbose": 0,
"max_features": float(max_features),
"max_leaf_nodes": params.get("max_leaf_nodes", int(round(max_leaves))),
}
)
self.params["verbose"] = 0
self.estimator_class = RandomForestRegressor
if task in ("binary", "multi"):
if task in CLASSIFICATION:
self.estimator_class = RandomForestClassifier
self.params["criterion"] = criterion
def get_params(self, deep=False):
params = super().get_params()
return params
class ExtraTreeEstimator(RandomForestEstimator):
@ -648,21 +584,16 @@ class LRL1Classifier(SKLearnEstimator):
def cost_relative2lgbm(cls):
return 160
def __init__(self, task="binary", n_jobs=1, tol=0.0001, C=1.0, **params):
super().__init__(task, **params)
self.params.update(
{
"penalty": params.get("penalty", "l1"),
"tol": float(tol),
"C": float(C),
"solver": params.get("solver", "saga"),
"n_jobs": n_jobs,
}
)
assert task in (
"binary",
"multi",
), "LogisticRegression for classification task only"
def config2params(cls, config: dict) -> dict:
params = config.copy()
params["tol"] = params.get("tol", 0.0001)
params["solver"] = params.get("solver", "saga")
params["penalty"] = params.get("penalty", "l1")
return params
def __init__(self, task="binary", **config):
super().__init__(task, **config)
assert task in CLASSIFICATION, "LogisticRegression for classification task only"
self.estimator_class = LogisticRegression
@ -675,21 +606,16 @@ class LRL2Classifier(SKLearnEstimator):
def cost_relative2lgbm(cls):
return 25
def __init__(self, task="binary", n_jobs=1, tol=0.0001, C=1.0, **params):
super().__init__(task, **params)
self.params.update(
{
"penalty": params.get("penalty", "l2"),
"tol": float(tol),
"C": float(C),
"solver": params.get("solver", "lbfgs"),
"n_jobs": n_jobs,
}
)
assert task in (
"binary",
"multi",
), "LogisticRegression for classification task only"
def config2params(cls, config: dict) -> dict:
params = config.copy()
params["tol"] = params.get("tol", 0.0001)
params["solver"] = params.get("solver", "lbfgs")
params["penalty"] = params.get("penalty", "l2")
return params
def __init__(self, task="binary", **config):
super().__init__(task, **config)
assert task in CLASSIFICATION, "LogisticRegression for classification task only"
self.estimator_class = LogisticRegression
@ -749,39 +675,33 @@ class CatBoostEstimator(BaseEstimator):
X = X.to_numpy()
return X
def config2params(cls, config: dict) -> dict:
params = config.copy()
params["n_estimators"] = params.get("n_estimators", 8192)
if "n_jobs" in params:
params["thread_count"] = params.pop("n_jobs")
return params
def __init__(
self,
task="binary",
n_jobs=1,
n_estimators=8192,
learning_rate=0.1,
early_stopping_rounds=4,
**params,
**config,
):
super().__init__(task, **params)
super().__init__(task, **config)
self.params.update(
{
"early_stopping_rounds": int(round(early_stopping_rounds)),
"n_estimators": n_estimators,
"learning_rate": learning_rate,
"thread_count": n_jobs,
"verbose": params.get("verbose", False),
"random_seed": params.get("random_seed", 10242048),
"verbose": config.get("verbose", False),
"random_seed": config.get("random_seed", 10242048),
}
)
from catboost import CatBoostRegressor
self.estimator_class = CatBoostRegressor
if task in ("binary", "multi"):
if task in CLASSIFICATION:
from catboost import CatBoostClassifier
self.estimator_class = CatBoostClassifier
def get_params(self, deep=False):
params = super().get_params()
params["n_jobs"] = params["thread_count"]
return params
def fit(self, X_train, y_train, budget=None, **kwargs):
import shutil
@ -881,7 +801,7 @@ class CatBoostEstimator(BaseEstimator):
kwargs["sample_weight"] = weight
self._model = model
else:
self.params["n_estimators"] = n_iter
self.params["n_estimators"] = self._model.tree_count_
# except CatBoostError:
# self._model = None
train_time = time.time() - start_time
@ -904,22 +824,21 @@ class KNeighborsEstimator(BaseEstimator):
def cost_relative2lgbm(cls):
return 30
def __init__(self, task="binary", n_jobs=1, n_neighbors=5, **params):
super().__init__(task, **params)
self.params.update(
{
"n_neighbors": int(round(n_neighbors)),
"weights": params.get("weights", "distance"),
"n_jobs": n_jobs,
}
)
from sklearn.neighbors import KNeighborsRegressor
def config2params(cls, config: dict) -> dict:
params = config.copy()
params["weights"] = params.get("weights", "distance")
return params
self.estimator_class = KNeighborsRegressor
if task in ("binary", "multi"):
def __init__(self, task="binary", **config):
super().__init__(task, **config)
if task in CLASSIFICATION:
from sklearn.neighbors import KNeighborsClassifier
self.estimator_class = KNeighborsClassifier
else:
from sklearn.neighbors import KNeighborsRegressor
self.estimator_class = KNeighborsRegressor
def _preprocess(self, X):
if isinstance(X, pd.DataFrame):
@ -963,9 +882,7 @@ class Prophet(BaseEstimator):
}
return space
def __init__(self, task="forecast", **params):
if "n_jobs" in params:
params.pop("n_jobs")
def __init__(self, task="forecast", n_jobs=1, **params):
super().__init__(task, **params)
def _join(self, X_train, y_train):

View File

@ -13,7 +13,7 @@ SEARCH_ALGO_MAPPING = OrderedDict(
("bs", BlendSearch),
("grid", None),
("gridbert", None),
("rs", None)
("rs", None),
]
)
@ -35,14 +35,16 @@ class AutoSearchAlgorithm:
)
@classmethod
def from_method_name(cls,
search_algo_name,
search_algo_args_mode,
hpo_search_space,
time_budget,
metric_name,
metric_mode_name,
**custom_hpo_args):
def from_method_name(
cls,
search_algo_name,
search_algo_args_mode,
hpo_search_space,
time_budget,
metric_name,
metric_mode_name,
**custom_hpo_args
):
"""
Instantiating one of the search algorithm classes based on the search algorithm name, search algorithm
argument mode, hpo search space and other keyword args
@ -68,7 +70,9 @@ class AutoSearchAlgorithm:
{"points_to_evaluate": [{"learning_rate": 1e-5, "num_train_epochs": 10}])
"""
assert hpo_search_space, "hpo_search_space needs to be specified for calling AutoSearchAlgorithm.from_method_name"
assert (
hpo_search_space
), "hpo_search_space needs to be specified for calling AutoSearchAlgorithm.from_method_name"
if not search_algo_name:
# TODO coverage
search_algo_name = "grid"
@ -83,9 +87,15 @@ class AutoSearchAlgorithm:
of the constructor function
"""
this_search_algo_kwargs = None
allowed_arguments = SEARCH_ALGO_MAPPING[search_algo_name].__init__.__code__.co_varnames
allowed_custom_args = {key: custom_hpo_args[key] for key in custom_hpo_args.keys() if
key in allowed_arguments}
allowed_arguments = SEARCH_ALGO_MAPPING[
search_algo_name
].__init__.__code__.co_varnames
custom_hpo_args["time_budget_s"] = time_budget
allowed_custom_args = {
key: custom_hpo_args[key]
for key in custom_hpo_args.keys()
if key in allowed_arguments
}
"""
If the search_algo_args_mode is "dft", set the args to the default args, e.g.,the default args for
@ -94,26 +104,34 @@ class AutoSearchAlgorithm:
"""
if search_algo_args_mode == "dft":
# TODO coverage
this_search_algo_kwargs = DEFAULT_SEARCH_ALGO_ARGS_MAPPING[search_algo_name](
this_search_algo_kwargs = DEFAULT_SEARCH_ALGO_ARGS_MAPPING[
search_algo_name
](
"dft",
metric_name,
metric_mode_name,
hpo_search_space=hpo_search_space,
**allowed_custom_args)
**allowed_custom_args
)
elif search_algo_args_mode == "cus":
this_search_algo_kwargs = DEFAULT_SEARCH_ALGO_ARGS_MAPPING[search_algo_name](
this_search_algo_kwargs = DEFAULT_SEARCH_ALGO_ARGS_MAPPING[
search_algo_name
](
"cus",
metric_name,
metric_mode_name,
hpo_search_space=hpo_search_space,
**allowed_custom_args)
**allowed_custom_args
)
"""
returning the hpo algorithm with the arguments
"""
search_algo = SEARCH_ALGO_MAPPING[search_algo_name](**this_search_algo_kwargs)
search_algo = SEARCH_ALGO_MAPPING[search_algo_name](
**this_search_algo_kwargs
)
if search_algo_name == "bs":
search_algo.set_search_properties(config={"time_budget_s": time_budget})
search_algo.set_search_properties()
return search_algo
raise ValueError(
"Unrecognized method {} for this kind of AutoSearchAlgorithm: {}.\n"
@ -125,29 +143,39 @@ class AutoSearchAlgorithm:
@staticmethod
def grid2list(grid_config):
# TODO coverage
key_val_list = [[(key, each_val) for each_val in val_list['grid_search']]
for (key, val_list) in grid_config.items()]
key_val_list = [
[(key, each_val) for each_val in val_list["grid_search"]]
for (key, val_list) in grid_config.items()
]
config_list = [dict(x) for x in itertools.product(*key_val_list)]
return config_list
def get_search_algo_args_optuna(search_args_mode,
metric_name,
metric_mode_name,
hpo_search_space=None,
**custom_hpo_args):
def get_search_algo_args_optuna(
search_args_mode,
metric_name,
metric_mode_name,
hpo_search_space=None,
**custom_hpo_args
):
# TODO coverage
return {}
def default_search_algo_args_bs(search_args_mode,
metric_name,
metric_mode_name,
hpo_search_space=None,
**custom_hpo_args):
assert hpo_search_space, "hpo_search_space needs to be specified for calling AutoSearchAlgorithm.from_method_name"
if "num_train_epochs" in hpo_search_space and \
isinstance(hpo_search_space["num_train_epochs"], ray.tune.sample.Categorical):
def default_search_algo_args_bs(
search_args_mode,
metric_name,
metric_mode_name,
hpo_search_space=None,
time_budget_s=None,
**custom_hpo_args
):
assert (
hpo_search_space
), "hpo_search_space needs to be specified for calling AutoSearchAlgorithm.from_method_name"
if "num_train_epochs" in hpo_search_space and isinstance(
hpo_search_space["num_train_epochs"], ray.tune.sample.Categorical
):
min_epoch = min(hpo_search_space["num_train_epochs"].categories)
else:
# TODO coverage
@ -156,31 +184,38 @@ def default_search_algo_args_bs(search_args_mode,
default_search_algo_args = {
"low_cost_partial_config": {
"num_train_epochs": min_epoch,
"per_device_train_batch_size": max(hpo_search_space["per_device_train_batch_size"].categories),
"per_device_train_batch_size": max(
hpo_search_space["per_device_train_batch_size"].categories
),
},
"space": hpo_search_space,
"metric": metric_name,
"mode": metric_mode_name
"mode": metric_mode_name,
"time_budget_s": time_budget_s,
}
if search_args_mode == "cus":
default_search_algo_args.update(custom_hpo_args)
return default_search_algo_args
def default_search_algo_args_grid_search(search_args_mode,
metric_name,
metric_mode_name,
hpo_search_space=None,
**custom_hpo_args):
def default_search_algo_args_grid_search(
search_args_mode,
metric_name,
metric_mode_name,
hpo_search_space=None,
**custom_hpo_args
):
# TODO coverage
return {}
def default_search_algo_args_random_search(search_args_mode,
metric_name,
metric_mode_name,
hpo_search_space=None,
**custom_hpo_args):
def default_search_algo_args_random_search(
search_args_mode,
metric_name,
metric_mode_name,
hpo_search_space=None,
**custom_hpo_args
):
# TODO coverage
return {}
@ -191,6 +226,5 @@ DEFAULT_SEARCH_ALGO_ARGS_MAPPING = OrderedDict(
("cfo", default_search_algo_args_bs),
("bs", default_search_algo_args_bs),
("grid", default_search_algo_args_grid_search),
("gridbert", default_search_algo_args_random_search)
]
)

View File

@ -1,10 +1,10 @@
import time
import numpy as np
import math
from flaml.tune import Trial
from flaml.scheduler import TrialScheduler
import logging
logger = logging.getLogger(__name__)
@ -45,16 +45,18 @@ class OnlineTrialRunner:
Status change routine of a trial
Trial.PENDING -> (Trial.RUNNING -> Trial.PAUSED -> Trial.RUNNING -> ...) -> Trial.TERMINATED(optional)
"""
RANDOM_SEED = 123456
WARMSTART_NUM = 100
def __init__(self,
max_live_model_num: int,
searcher=None,
scheduler=None,
champion_test_policy='loss_ucb',
**kwargs
):
def __init__(
self,
max_live_model_num: int,
searcher=None,
scheduler=None,
champion_test_policy="loss_ucb",
**kwargs
):
"""Constructor
Args:
@ -64,7 +66,7 @@ class OnlineTrialRunner:
Required methods of the searcher:
- next_trial()
Generate the next trial to add.
- set_search_properties(metric: Optional[str], mode: Optional[str], config: dict)
- set_search_properties(metric: Optional[str], mode: Optional[str], config: Optional[dict], setting: Optional[dict])
Generate new challengers based on the current champion and update the challenger list
- on_trial_result(trial_id: str, result: Dict)
Reprot results to the scheduler.
@ -87,8 +89,8 @@ class OnlineTrialRunner:
self._scheduler = scheduler
self._champion_test_policy = champion_test_policy
self._max_live_model_num = max_live_model_num
self._remove_worse = kwargs.get('remove_worse', True)
self._bound_trial_num = kwargs.get('bound_trial_num', False)
self._remove_worse = kwargs.get("remove_worse", True)
self._bound_trial_num = kwargs.get("bound_trial_num", False)
self._no_model_persistence = True
# stores all the trials added to the OnlineTrialRunner
@ -103,21 +105,19 @@ class OnlineTrialRunner:
# initially schedule up to max_live_model_num of live models and
# set the first trial as the champion (which is done inside self.step())
self._total_steps = 0
logger.info('init step %s', self._max_live_model_num)
logger.info("init step %s", self._max_live_model_num)
# TODO: add more comments
self.step()
assert self._champion_trial is not None
@property
def champion_trial(self) -> Trial:
"""The champion trial
"""
"""The champion trial"""
return self._champion_trial
@property
def running_trials(self):
"""The running/'live' trials
"""
"""The running/'live' trials"""
return self._running_trials
def step(self, data_sample=None, prediction_trial_tuple=None):
@ -147,7 +147,10 @@ class OnlineTrialRunner:
# ***********Update running trials with observation***************************
if data_sample is not None:
self._total_steps += 1
prediction_made, prediction_trial = prediction_trial_tuple[0], prediction_trial_tuple[1]
prediction_made, prediction_trial = (
prediction_trial_tuple[0],
prediction_trial_tuple[1],
)
# assert prediction_trial.status == Trial.RUNNING
trials_to_pause = []
for trial in list(self._running_trials):
@ -156,16 +159,27 @@ class OnlineTrialRunner:
else:
y_predicted = prediction_made
trial.train_eval_model_online(data_sample, y_predicted)
logger.debug('running trial at iter %s %s %s %s %s %s', self._total_steps,
trial.trial_id, trial.result.loss_avg, trial.result.loss_cb,
trial.result.resource_used, trial.resource_lease)
logger.debug(
"running trial at iter %s %s %s %s %s %s",
self._total_steps,
trial.trial_id,
trial.result.loss_avg,
trial.result.loss_cb,
trial.result.resource_used,
trial.resource_lease,
)
# report result to the searcher
self._searcher.on_trial_result(trial.trial_id, trial.result)
# report result to the scheduler and the scheduler makes a decision about
# the running status of the trial
decision = self._scheduler.on_trial_result(self, trial, trial.result)
# set the status of the trial according to the decision made by the scheduler
logger.debug('trial decision %s %s at step %s', decision, trial.trial_id, self._total_steps)
logger.debug(
"trial decision %s %s at step %s",
decision,
trial.trial_id,
self._total_steps,
)
if decision == TrialScheduler.STOP:
self.stop_trial(trial)
elif decision == TrialScheduler.PAUSE:
@ -191,38 +205,45 @@ class OnlineTrialRunner:
else:
break
def get_top_running_trials(self, top_ratio=None, top_metric='ucb') -> list:
"""Get a list of trial ids, whose performance is among the top running trials
"""
running_valid_trials = [trial for trial in self._running_trials if
trial.result is not None]
def get_top_running_trials(self, top_ratio=None, top_metric="ucb") -> list:
"""Get a list of trial ids, whose performance is among the top running trials"""
running_valid_trials = [
trial for trial in self._running_trials if trial.result is not None
]
if not running_valid_trials:
return
if top_ratio is None:
top_number = 0
elif isinstance(top_ratio, float):
top_number = math.ceil(len(running_valid_trials) * top_ratio)
elif isinstance(top_ratio, str) and 'best' in top_ratio:
elif isinstance(top_ratio, str) and "best" in top_ratio:
top_number = 1
else:
raise NotImplementedError
if 'ucb' in top_metric:
test_attribute = 'loss_ucb'
elif 'avg' in top_metric:
test_attribute = 'loss_avg'
elif 'lcb' in top_metric:
test_attribute = 'loss_lcb'
if "ucb" in top_metric:
test_attribute = "loss_ucb"
elif "avg" in top_metric:
test_attribute = "loss_avg"
elif "lcb" in top_metric:
test_attribute = "loss_lcb"
else:
raise NotImplementedError
top_running_valid_trials = []
logger.info('Running trial ids %s', [trial.trial_id for trial in running_valid_trials])
logger.info(
"Running trial ids %s", [trial.trial_id for trial in running_valid_trials]
)
self._random_state.shuffle(running_valid_trials)
results = [trial.result.get_score(test_attribute) for trial in running_valid_trials]
sorted_index = np.argsort(np.array(results)) # sorted result (small to large) index
results = [
trial.result.get_score(test_attribute) for trial in running_valid_trials
]
# sorted result (small to large) index
sorted_index = np.argsort(np.array(results))
for i in range(min(top_number, len(running_valid_trials))):
top_running_valid_trials.append(running_valid_trials[sorted_index[i]])
logger.info('Top running ids %s', [trial.trial_id for trial in top_running_valid_trials])
logger.info(
"Top running ids %s", [trial.trial_id for trial in top_running_valid_trials]
)
return top_running_valid_trials
def _add_trial_from_searcher(self):
@ -234,12 +255,25 @@ class OnlineTrialRunner:
"""
# (optionally) upper bound the number of trials in the OnlineTrialRunner
if self._bound_trial_num and self._first_challenger_pool_size is not None:
active_trial_size = len([t for t in self._trials if t.status != Trial.TERMINATED])
trial_num_upper_bound = int(round((np.log10(self._total_steps) + 1) * self._first_challenger_pool_size)
) if self._first_challenger_pool_size else np.inf
active_trial_size = len(
[t for t in self._trials if t.status != Trial.TERMINATED]
)
trial_num_upper_bound = (
int(
round(
(np.log10(self._total_steps) + 1)
* self._first_challenger_pool_size
)
)
if self._first_challenger_pool_size
else np.inf
)
if active_trial_size > trial_num_upper_bound:
logger.info('Not adding new trials: %s exceeds trial limit %s.',
active_trial_size, trial_num_upper_bound)
logger.info(
"Not adding new trials: %s exceeds trial limit %s.",
active_trial_size,
trial_num_upper_bound,
)
return None
# output one trial from the trial pool (new challenger pool) maintained in the searcher
@ -253,7 +287,7 @@ class OnlineTrialRunner:
# a valid trial is added.
# Assumption on self._searcher: the first trial generated is the champion trial
if self._champion_trial is None:
logger.info('Initial set up of the champion trial %s', trial.config)
logger.info("Initial set up of the champion trial %s", trial.config)
self._set_champion(trial)
else:
self._all_new_challengers_added = True
@ -261,14 +295,15 @@ class OnlineTrialRunner:
self._first_challenger_pool_size = len(self._trials)
def _champion_test(self):
"""Perform tests again the latest champion, including bette_than tests and worse_than tests
"""
"""Perform tests again the latest champion, including bette_than tests and worse_than tests"""
# for BetterThan test, we only need to compare the best challenger with the champion
self._get_best_challenger()
if self._best_challenger_trial is not None:
assert self._best_challenger_trial.trial_id != self._champion_trial.trial_id
# test whether a new champion is found and set the trial properties accordingly
is_new_champion_found = self._better_than_champion_test(self._best_challenger_trial)
is_new_champion_found = self._better_than_champion_test(
self._best_challenger_trial
)
if is_new_champion_found:
self._set_champion(new_champion_trial=self._best_challenger_trial)
@ -278,39 +313,47 @@ class OnlineTrialRunner:
for trial_to_test in self._trials:
if trial_to_test.status != Trial.TERMINATED:
worse_than_champion = self._worse_than_champion_test(
self._champion_trial, trial_to_test, self.WARMSTART_NUM)
self._champion_trial, trial_to_test, self.WARMSTART_NUM
)
if worse_than_champion:
to_stop.append(trial_to_test)
# we want to ensure there are at least #max_live_model_num of challengers remaining
max_to_stop_num = len([t for t in self._trials if t.status != Trial.TERMINATED]
) - self._max_live_model_num
max_to_stop_num = (
len([t for t in self._trials if t.status != Trial.TERMINATED])
- self._max_live_model_num
)
for i in range(min(max_to_stop_num, len(to_stop))):
self.stop_trial(to_stop[i])
def _get_best_challenger(self):
"""Get the 'best' (in terms of the champion_test_policy) challenger under consideration.
"""
"""Get the 'best' (in terms of the champion_test_policy) challenger under consideration."""
if self._champion_test_policy is None:
return
if 'ucb' in self._champion_test_policy:
test_attribute = 'loss_ucb'
elif 'avg' in self._champion_test_policy:
test_attribute = 'loss_avg'
if "ucb" in self._champion_test_policy:
test_attribute = "loss_ucb"
elif "avg" in self._champion_test_policy:
test_attribute = "loss_avg"
else:
raise NotImplementedError
active_trials = [trial for trial in self._trials if
(trial.status != Trial.TERMINATED
and trial.trial_id != self._champion_trial.trial_id
and trial.result is not None)]
active_trials = [
trial
for trial in self._trials
if (
trial.status != Trial.TERMINATED
and trial.trial_id != self._champion_trial.trial_id
and trial.result is not None
)
]
if active_trials:
self._random_state.shuffle(active_trials)
results = [trial.result.get_score(test_attribute) for trial in active_trials]
results = [
trial.result.get_score(test_attribute) for trial in active_trials
]
best_index = np.argmin(results)
self._best_challenger_trial = active_trials[best_index]
def _set_champion(self, new_champion_trial):
"""Set the status of the existing trials once a new champion is found.
"""
"""Set the status of the existing trials once a new champion is found."""
assert new_champion_trial is not None
is_init_update = False
if self._champion_trial is None:
@ -324,21 +367,20 @@ class OnlineTrialRunner:
trial.set_checked_under_current_champion(False)
self._champion_trial = new_champion_trial
self._all_new_challengers_added = False
logger.info('Set the champion as %s', self._champion_trial.trial_id)
logger.info("Set the champion as %s", self._champion_trial.trial_id)
if not is_init_update:
self._champion_update_times += 1
# calling set_search_properties of searcher will trigger
# new challenger generation. we do not do this for init champion
# as this step is already done when first constructing the searcher
self._searcher.set_search_properties(None, None,
{self._searcher.CHAMPION_TRIAL_NAME: self._champion_trial}
)
self._searcher.set_search_properties(
setting={self._searcher.CHAMPION_TRIAL_NAME: self._champion_trial}
)
else:
self._champion_update_times = 0
def get_trials(self) -> list:
"""Return the list of trials managed by this TrialRunner.
"""
"""Return the list of trials managed by this TrialRunner."""
return self._trials
def add_trial(self, new_trial):
@ -357,8 +399,12 @@ class OnlineTrialRunner:
if trial.trial_id == new_trial.trial_id:
trial.set_checked_under_current_champion(True)
return
logger.info('adding trial at iter %s, %s %s', self._total_steps, new_trial.trial_id,
len(self._trials))
logger.info(
"adding trial at iter %s, %s %s",
self._total_steps,
new_trial.trial_id,
len(self._trials),
)
self._trials.append(new_trial)
self._scheduler.on_trial_add(self, new_trial)
@ -369,8 +415,11 @@ class OnlineTrialRunner:
if trial.status in [Trial.ERROR, Trial.TERMINATED]:
return
else:
logger.info('Terminating trial %s, with trial result %s',
trial.trial_id, trial.result)
logger.info(
"Terminating trial %s, with trial result %s",
trial.trial_id,
trial.result,
)
trial.set_status(Trial.TERMINATED)
# clean up model and result
trial.clean_up_model()
@ -385,10 +434,15 @@ class OnlineTrialRunner:
if trial.status in [Trial.ERROR, Trial.TERMINATED]:
return
else:
logger.info('Pausing trial %s, with trial loss_avg: %s, loss_cb: %s, loss_ucb: %s,\
resource_lease: %s', trial.trial_id, trial.result.loss_avg,
trial.result.loss_cb, trial.result.loss_avg + trial.result.loss_cb,
trial.resource_lease)
logger.info(
"Pausing trial %s, with trial loss_avg: %s, loss_cb: %s, loss_ucb: %s,\
resource_lease: %s",
trial.trial_id,
trial.result.loss_avg,
trial.result.loss_cb,
trial.result.loss_avg + trial.result.loss_cb,
trial.resource_lease,
)
trial.set_status(Trial.PAUSED)
# clean up model and result if no model persistence
if self._no_model_persistence:
@ -413,11 +467,15 @@ class OnlineTrialRunner:
A bool indicating whether a new champion is found
"""
if trial_to_test.result is not None and self._champion_trial.result is not None:
if 'ucb' in self._champion_test_policy:
return self._test_lcb_ucb(self._champion_trial, trial_to_test, self.WARMSTART_NUM)
elif 'avg' in self._champion_test_policy:
return self._test_avg_loss(self._champion_trial, trial_to_test, self.WARMSTART_NUM)
elif 'martingale' in self._champion_test_policy:
if "ucb" in self._champion_test_policy:
return self._test_lcb_ucb(
self._champion_trial, trial_to_test, self.WARMSTART_NUM
)
elif "avg" in self._champion_test_policy:
return self._test_avg_loss(
self._champion_trial, trial_to_test, self.WARMSTART_NUM
)
elif "martingale" in self._champion_test_policy:
return self._test_martingale(self._champion_trial, trial_to_test)
else:
raise NotImplementedError
@ -426,22 +484,38 @@ class OnlineTrialRunner:
@staticmethod
def _worse_than_champion_test(champion_trial, trial, warmstart_num=1) -> bool:
"""Test whether the input trial is worse than the champion_trial
"""
"""Test whether the input trial is worse than the champion_trial"""
if trial.result is not None and trial.result.resource_used >= warmstart_num:
if trial.result.loss_lcb > champion_trial.result.loss_ucb:
logger.info('=========trial %s is worse than champion %s=====',
trial.trial_id, champion_trial.trial_id)
logger.info('trial %s %s %s', trial.config, trial.result, trial.resource_lease)
logger.info('trial loss_avg:%s, trial loss_cb %s', trial.result.loss_avg,
trial.result.loss_cb)
logger.info('champion loss_avg:%s, champion loss_cb %s', champion_trial.result.loss_avg,
champion_trial.result.loss_cb)
logger.info('champion %s', champion_trial.config)
logger.info('trial loss_avg_recent:%s, trial loss_cb %s', trial.result.loss_avg_recent,
trial.result.loss_cb)
logger.info('champion loss_avg_recent:%s, champion loss_cb %s',
champion_trial.result.loss_avg_recent, champion_trial.result.loss_cb)
logger.info(
"=========trial %s is worse than champion %s=====",
trial.trial_id,
champion_trial.trial_id,
)
logger.info(
"trial %s %s %s", trial.config, trial.result, trial.resource_lease
)
logger.info(
"trial loss_avg:%s, trial loss_cb %s",
trial.result.loss_avg,
trial.result.loss_cb,
)
logger.info(
"champion loss_avg:%s, champion loss_cb %s",
champion_trial.result.loss_avg,
champion_trial.result.loss_cb,
)
logger.info("champion %s", champion_trial.config)
logger.info(
"trial loss_avg_recent:%s, trial loss_cb %s",
trial.result.loss_avg_recent,
trial.result.loss_cb,
)
logger.info(
"champion loss_avg_recent:%s, champion loss_cb %s",
champion_trial.result.loss_avg_recent,
champion_trial.result.loss_cb,
)
return True
return False
@ -452,18 +526,35 @@ class OnlineTrialRunner:
"""
assert trial.trial_id != champion_trial.trial_id
if trial.result.resource_used >= warmstart_num:
if trial.result.loss_ucb < champion_trial.result.loss_lcb - champion_trial.result.loss_cb:
logger.info('======new champion condition satisfied: using lcb vs ucb=====')
logger.info('new champion trial %s %s %s',
trial.trial_id, trial.result.resource_used, trial.resource_lease)
logger.info('new champion trial loss_avg:%s, trial loss_cb %s',
trial.result.loss_avg, trial.result.loss_cb)
logger.info('old champion trial %s %s %s',
champion_trial.trial_id, champion_trial.result.resource_used,
champion_trial.resource_lease,)
logger.info('old champion loss avg %s, loss cb %s',
champion_trial.result.loss_avg,
champion_trial.result.loss_cb)
if (
trial.result.loss_ucb
< champion_trial.result.loss_lcb - champion_trial.result.loss_cb
):
logger.info(
"======new champion condition satisfied: using lcb vs ucb====="
)
logger.info(
"new champion trial %s %s %s",
trial.trial_id,
trial.result.resource_used,
trial.resource_lease,
)
logger.info(
"new champion trial loss_avg:%s, trial loss_cb %s",
trial.result.loss_avg,
trial.result.loss_cb,
)
logger.info(
"old champion trial %s %s %s",
champion_trial.trial_id,
champion_trial.result.resource_used,
champion_trial.resource_lease,
)
logger.info(
"old champion loss avg %s, loss cb %s",
champion_trial.result.loss_avg,
champion_trial.result.loss_cb,
)
return True
return False
@ -475,13 +566,19 @@ class OnlineTrialRunner:
assert trial.trial_id != champion_trial.trial_id
if trial.result.resource_used >= warmstart_num:
if trial.result.loss_avg < champion_trial.result.loss_avg:
logger.info('=====new champion condition satisfied using avg loss=====')
logger.info('trial %s', trial.config)
logger.info('trial loss_avg:%s, trial loss_cb %s',
trial.result.loss_avg, trial.result.loss_cb)
logger.info('champion loss_avg:%s, champion loss_cb %s',
champion_trial.result.loss_avg, champion_trial.result.loss_cb)
logger.info('champion %s', champion_trial.config)
logger.info("=====new champion condition satisfied using avg loss=====")
logger.info("trial %s", trial.config)
logger.info(
"trial loss_avg:%s, trial loss_cb %s",
trial.result.loss_avg,
trial.result.loss_cb,
)
logger.info(
"champion loss_avg:%s, champion loss_cb %s",
champion_trial.result.loss_avg,
champion_trial.result.loss_cb,
)
logger.info("champion %s", champion_trial.config)
return True
return False

View File

@ -129,11 +129,12 @@ class BlendSearch(Searcher):
self._metric, self._mode = metric, mode
init_config = low_cost_partial_config or {}
if not init_config:
logger.warning(
logger.info(
"No low-cost partial config given to the search algorithm. "
"For cost-frugal search, "
"consider providing low-cost values for cost-related hps via "
"'low_cost_partial_config'."
"'low_cost_partial_config'. More info can be found at "
"https://github.com/microsoft/FLAML/wiki/About-%60low_cost_partial_config%60"
)
if evaluated_rewards and mode:
self._points_to_evaluate = []
@ -228,6 +229,7 @@ class BlendSearch(Searcher):
metric: Optional[str] = None,
mode: Optional[str] = None,
config: Optional[Dict] = None,
setting: Optional[Dict] = None,
) -> bool:
metric_changed = mode_changed = False
if metric and self._metric != metric:
@ -264,22 +266,22 @@ class BlendSearch(Searcher):
)
self._gs.space = self._ls.space
self._init_search()
if config:
# CFO doesn't need these settings
if "time_budget_s" in config:
self._time_budget_s = config["time_budget_s"] # budget from now
now = time.time()
self._time_used += now - self._start_time
self._start_time = now
self._set_deadline()
if "metric_target" in config:
self._metric_target = config.get("metric_target")
if "num_samples" in config:
self._num_samples = (
config["num_samples"]
+ len(self._result)
+ len(self._trial_proposed_by)
)
if setting:
# CFO doesn't need these settings
if "time_budget_s" in setting:
self._time_budget_s = setting["time_budget_s"] # budget from now
now = time.time()
self._time_used += now - self._start_time
self._start_time = now
self._set_deadline()
if "metric_target" in setting:
self._metric_target = setting.get("metric_target")
if "num_samples" in setting:
self._num_samples = (
setting["num_samples"]
+ len(self._result)
+ len(self._trial_proposed_by)
)
return True
def _set_deadline(self):

View File

@ -1,14 +1,16 @@
'''!
* Copyright (c) 2020-2021 Microsoft Corporation. All rights reserved.
"""!
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See LICENSE file in the
* project root for license information.
'''
"""
from flaml.tune.sample import Domain
from typing import Dict, Optional, Tuple
import numpy as np
try:
from ray import __version__ as ray_version
assert ray_version >= '1.0.0'
assert ray_version >= "1.0.0"
from ray.tune.suggest import Searcher
from ray.tune.suggest.variant_generator import generate_variants
from ray.tune import sample
@ -22,28 +24,30 @@ from ..tune.space import complete_config, denormalize, normalize
import logging
logger = logging.getLogger(__name__)
class FLOW2(Searcher):
'''Local search algorithm FLOW2, with adaptive step size
'''
"""Local search algorithm FLOW2, with adaptive step size"""
STEPSIZE = 0.1
STEP_LOWER_BOUND = 0.0001
def __init__(self,
init_config: dict,
metric: Optional[str] = None,
mode: Optional[str] = None,
space: Optional[dict] = None,
prune_attr: Optional[str] = None,
min_resource: Optional[float] = None,
max_resource: Optional[float] = None,
resource_multiple_factor: Optional[float] = 4,
cost_attr: Optional[str] = 'time_total_s',
seed: Optional[int] = 20):
'''Constructor
def __init__(
self,
init_config: dict,
metric: Optional[str] = None,
mode: Optional[str] = None,
space: Optional[dict] = None,
prune_attr: Optional[str] = None,
min_resource: Optional[float] = None,
max_resource: Optional[float] = None,
resource_multiple_factor: Optional[float] = 4,
cost_attr: Optional[str] = "time_total_s",
seed: Optional[int] = 20,
):
"""Constructor
Args:
init_config: a dictionary of a partial or full initial config,
@ -79,20 +83,18 @@ class FLOW2(Searcher):
used for increasing resource.
cost_attr: A string of the attribute used for cost.
seed: An integer of the random seed.
'''
"""
if mode:
assert mode in ["min", "max"], "`mode` must be 'min' or 'max'."
else:
mode = "min"
super(FLOW2, self).__init__(
metric=metric,
mode=mode)
super(FLOW2, self).__init__(metric=metric, mode=mode)
# internally minimizes, so "max" => -1
if mode == "max":
self.metric_op = -1.
self.metric_op = -1.0
elif mode == "min":
self.metric_op = 1.
self.metric_op = 1.0
self.space = space or {}
self._space = flatten_dict(self.space, prevent_delimiter=True)
self._random = np.random.RandomState(seed)
@ -106,7 +108,7 @@ class FLOW2(Searcher):
self.max_resource = max_resource
self._resource = None
self._step_lb = np.Inf
if space:
if space is not None:
self._init_search()
def _init_search(self):
@ -115,9 +117,10 @@ class FLOW2(Searcher):
self._unordered_cat_hp = {}
hier = False
for key, domain in self._space.items():
assert not (isinstance(domain, dict) and 'grid_search' in domain), \
f"{key}'s domain is grid search, not supported in FLOW^2."
if callable(getattr(domain, 'get_sampler', None)):
assert not (
isinstance(domain, dict) and "grid_search" in domain
), f"{key}'s domain is grid search, not supported in FLOW^2."
if callable(getattr(domain, "get_sampler", None)):
self._tunable_keys.append(key)
sampler = domain.get_sampler()
# the step size lower bound for uniform variables doesn't depend
@ -125,12 +128,14 @@ class FLOW2(Searcher):
if isinstance(sampler, sample.Quantized):
q = sampler.q
sampler = sampler.get_sampler()
if str(sampler) == 'Uniform':
if str(sampler) == "Uniform":
self._step_lb = min(
self._step_lb, q / (domain.upper - domain.lower))
elif isinstance(domain, sample.Integer) and str(sampler) == 'Uniform':
self._step_lb, q / (domain.upper - domain.lower)
)
elif isinstance(domain, sample.Integer) and str(sampler) == "Uniform":
self._step_lb = min(
self._step_lb, 1.0 / (domain.upper - 1 - domain.lower))
self._step_lb, 1.0 / (domain.upper - 1 - domain.lower)
)
if isinstance(domain, sample.Categorical):
if not domain.ordered:
self._unordered_cat_hp[key] = len(domain.categories)
@ -139,13 +144,12 @@ class FLOW2(Searcher):
if isinstance(cat, dict):
hier = True
break
if str(sampler) != 'Normal':
if str(sampler) != "Normal":
self._bounded_keys.append(key)
if not hier:
self._space_keys = sorted(self._tunable_keys)
self.hierarchical = hier
if (self.prune_attr and self.prune_attr not in self._space
and self.max_resource):
if self.prune_attr and self.prune_attr not in self._space and self.max_resource:
self.min_resource = self.min_resource or self._min_resource()
self._resource = self._round(self.min_resource)
if not hier:
@ -169,10 +173,11 @@ class FLOW2(Searcher):
if self.step > self.step_ub:
self.step = self.step_ub
# maximal # consecutive no improvements
self.dir = 2**(min(9, self.dim))
self.dir = 2 ** (min(9, self.dim))
self._configs = {} # dict from trial_id to (config, stepsize)
self._K = 0
self._iter_best_config = self.trial_count_proposed = self.trial_count_complete = 1
self._iter_best_config = 1
self.trial_count_proposed = self.trial_count_complete = 1
self._num_proposedby_incumbent = 0
self._reset_times = 0
# record intermediate trial cost
@ -196,14 +201,18 @@ class FLOW2(Searcher):
if isinstance(sampler, sample.Quantized):
q = sampler.q
sampler_inner = sampler.get_sampler()
if str(sampler_inner) == 'LogUniform':
if str(sampler_inner) == "LogUniform":
step_lb = min(
step_lb, np.log(1.0 + q / self.best_config[key])
/ np.log(domain.upper / domain.lower))
elif isinstance(domain, sample.Integer) and str(sampler) == 'LogUniform':
step_lb,
np.log(1.0 + q / self.best_config[key])
/ np.log(domain.upper / domain.lower),
)
elif isinstance(domain, sample.Integer) and str(sampler) == "LogUniform":
step_lb = min(
step_lb, np.log(1.0 + 1.0 / self.best_config[key])
/ np.log((domain.upper - 1) / domain.lower))
step_lb,
np.log(1.0 + 1.0 / self.best_config[key])
/ np.log((domain.upper - 1) / domain.lower),
)
if np.isinf(step_lb):
step_lb = self.STEP_LOWER_BOUND
else:
@ -215,13 +224,11 @@ class FLOW2(Searcher):
return self._resource
def _min_resource(self) -> float:
''' automatically decide minimal resource
'''
"""automatically decide minimal resource"""
return self.max_resource / np.pow(self.resource_multiple_factor, 5)
def _round(self, resource) -> float:
''' round the resource to self.max_resource if close to it
'''
"""round the resource to self.max_resource if close to it"""
if resource * self.resource_multiple_factor > self.max_resource:
return self.max_resource
return resource
@ -231,70 +238,83 @@ class FLOW2(Searcher):
return vec
def complete_config(
self, partial_config: Dict,
lower: Optional[Dict] = None, upper: Optional[Dict] = None
self,
partial_config: Dict,
lower: Optional[Dict] = None,
upper: Optional[Dict] = None,
) -> Tuple[Dict, Dict]:
''' generate a complete config from the partial config input
"""generate a complete config from the partial config input
add minimal resource to config if available
'''
"""
disturb = self._reset_times and partial_config == self.init_config
# if not the first time to complete init_config, use random gaussian
config, space = complete_config(
partial_config, self.space, self, disturb, lower, upper)
partial_config, self.space, self, disturb, lower, upper
)
if partial_config == self.init_config:
self._reset_times += 1
if self._resource:
config[self.prune_attr] = self.min_resource
return config, space
def create(self, init_config: Dict, obj: float, cost: float, space: Dict
) -> Searcher:
def create(
self, init_config: Dict, obj: float, cost: float, space: Dict
) -> Searcher:
# space is the subspace where the init_config is located
flow2 = self.__class__(
init_config, self.metric, self.mode,
space, self.prune_attr,
self.min_resource, self.max_resource,
self.resource_multiple_factor, self.cost_attr, self.seed + 1)
init_config,
self.metric,
self.mode,
space,
self.prune_attr,
self.min_resource,
self.max_resource,
self.resource_multiple_factor,
self.cost_attr,
self.seed + 1,
)
flow2.best_obj = obj * self.metric_op # minimize internally
flow2.cost_incumbent = cost
self.seed += 1
return flow2
def normalize(self, config, recursive=False) -> Dict:
''' normalize each dimension in config to [0,1]
'''
"""normalize each dimension in config to [0,1]"""
return normalize(
config, self._space, self.best_config, self.incumbent, recursive)
config, self._space, self.best_config, self.incumbent, recursive
)
def denormalize(self, config):
''' denormalize each dimension in config from [0,1]
'''
"""denormalize each dimension in config from [0,1]"""
return denormalize(
config, self._space, self.best_config, self.incumbent, self._random)
config, self._space, self.best_config, self.incumbent, self._random
)
def set_search_properties(self,
metric: Optional[str] = None,
mode: Optional[str] = None,
config: Optional[Dict] = None) -> bool:
def set_search_properties(
self,
metric: Optional[str] = None,
mode: Optional[str] = None,
config: Optional[Dict] = None,
) -> bool:
if metric:
self._metric = metric
if mode:
assert mode in ["min", "max"], "`mode` must be 'min' or 'max'."
self._mode = mode
if mode == "max":
self.metric_op = -1.
self.metric_op = -1.0
elif mode == "min":
self.metric_op = 1.
self.metric_op = 1.0
if config:
self.space = config
self._space = flatten_dict(self.space)
self._init_search()
return True
def on_trial_complete(self, trial_id: str, result: Optional[Dict] = None,
error: bool = False):
''' compare with incumbent
'''
def on_trial_complete(
self, trial_id: str, result: Optional[Dict] = None, error: bool = False
):
# compare with incumbent
# if better, move, reset num_complete and num_proposed
# if not better and num_complete >= 2*dim, num_allowed += 2
self.trial_count_complete += 1
@ -329,15 +349,19 @@ class FLOW2(Searcher):
if proposed_by == self.incumbent:
# proposed by current incumbent and no better
self._num_complete4incumbent += 1
cost = result.get(
self.cost_attr) if result else self._trial_cost.get(trial_id)
cost = (
result.get(self.cost_attr) if result else self._trial_cost.get(trial_id)
)
if cost:
self._cost_complete4incumbent += cost
if self._num_complete4incumbent >= 2 * self.dim and \
self._num_allowed4incumbent == 0:
if (
self._num_complete4incumbent >= 2 * self.dim
and self._num_allowed4incumbent == 0
):
self._num_allowed4incumbent = 2
if self._num_complete4incumbent == self.dir and (
not self._resource or self._resource == self.max_resource):
not self._resource or self._resource == self.max_resource
):
# check stuck condition if using max resource
self._num_complete4incumbent -= 2
if self._num_allowed4incumbent < 2:
@ -345,8 +369,7 @@ class FLOW2(Searcher):
# elif proposed_by: del self._proposed_by[trial_id]
def on_trial_result(self, trial_id: str, result: Dict):
''' early update of incumbent
'''
"""early update of incumbent"""
if result:
obj = result.get(self._metric)
if obj:
@ -373,27 +396,32 @@ class FLOW2(Searcher):
def rand_vector_unit_sphere(self, dim, trunc=0) -> np.ndarray:
vec = self._random.normal(0, 1, dim)
if 0 < trunc < dim:
vec[np.abs(vec).argsort()[:dim - trunc]] = 0
vec[np.abs(vec).argsort()[: dim - trunc]] = 0
mag = np.linalg.norm(vec)
return vec / mag
def suggest(self, trial_id: str) -> Optional[Dict]:
''' suggest a new config, one of the following cases:
"""suggest a new config, one of the following cases:
1. same incumbent, increase resource
2. same resource, move from the incumbent to a random direction
3. same resource, move from the incumbent to the opposite direction
#TODO: better decouple FLOW2 config suggestion and stepsize update
'''
"""
self.trial_count_proposed += 1
if self._num_complete4incumbent > 0 and self.cost_incumbent and \
self._resource and self._resource < self.max_resource and (
if (
self._num_complete4incumbent > 0
and self.cost_incumbent
and self._resource
and self._resource < self.max_resource
and (
self._cost_complete4incumbent
>= self.cost_incumbent * self.resource_multiple_factor):
>= self.cost_incumbent * self.resource_multiple_factor
)
):
# consider increasing resource using sum eval cost of complete
# configs
old_resource = self._resource
self._resource = self._round(
self._resource * self.resource_multiple_factor)
self._resource = self._round(self._resource * self.resource_multiple_factor)
self.cost_incumbent *= self._resource / old_resource
config = self.best_config.copy()
config[self.prune_attr] = self._resource
@ -409,8 +437,9 @@ class FLOW2(Searcher):
self._direction_tried = None
else:
# propose a new direction
self._direction_tried = self.rand_vector_unit_sphere(
self.dim, self._trunc) * self.step
self._direction_tried = (
self.rand_vector_unit_sphere(self.dim, self._trunc) * self.step
)
for i, key in enumerate(self._tunable_keys):
move[key] += self._direction_tried[i]
self._project(move)
@ -442,7 +471,8 @@ class FLOW2(Searcher):
break
self._same = same
if self._num_proposedby_incumbent == self.dir and (
not self._resource or self._resource == self.max_resource):
not self._resource or self._resource == self.max_resource
):
# check stuck condition if using max resource
self._num_proposedby_incumbent -= 2
self._init_phase = False
@ -459,11 +489,11 @@ class FLOW2(Searcher):
# random
for i, key in enumerate(self._tunable_keys):
if self._direction_tried[i] != 0:
for _, generated in generate_variants({'config': {
key: self._space[key]
}}):
if generated['config'][key] != best_config[key]:
config[key] = generated['config'][key]
for _, generated in generate_variants(
{"config": {key: self._space[key]}}
):
if generated["config"][key] != best_config[key]:
config[key] = generated["config"][key]
return unflatten_dict(config)
break
else:
@ -477,8 +507,7 @@ class FLOW2(Searcher):
return unflatten_dict(config)
def _project(self, config):
''' project normalized config in the feasible region and set prune_attr
'''
"""project normalized config in the feasible region and set prune_attr"""
for key in self._bounded_keys:
value = config[key]
config[key] = max(0, min(1, value))
@ -487,14 +516,13 @@ class FLOW2(Searcher):
@property
def can_suggest(self) -> bool:
''' can't suggest if 2*dim configs have been proposed for the incumbent
while fewer are completed
'''
"""can't suggest if 2*dim configs have been proposed for the incumbent
while fewer are completed
"""
return self._num_allowed4incumbent > 0
def config_signature(self, config, space: Dict = None) -> tuple:
''' return the signature tuple of a config
'''
"""return the signature tuple of a config"""
config = flatten_dict(config)
if space:
space = flatten_dict(space)
@ -514,8 +542,11 @@ class FLOW2(Searcher):
if self.hierarchical:
# can't remove constant for hierarchical search space,
# e.g., learner
if not (domain is None or type(domain) in (str, int, float)
or isinstance(domain, sample.Domain)):
if not (
domain is None
or type(domain) in (str, int, float)
or isinstance(domain, sample.Domain)
):
# not domain or hashable
# get rid of list type for hierarchical search space.
continue
@ -527,16 +558,14 @@ class FLOW2(Searcher):
@property
def converged(self) -> bool:
''' return whether the local search has converged
'''
"""return whether the local search has converged"""
if self._num_complete4incumbent < self.dir - 2:
return False
# check stepsize after enough configs are completed
return self.step < self.step_lower_bound
def reach(self, other: Searcher) -> bool:
''' whether the incumbent can reach the incumbent of other
'''
"""whether the incumbent can reach the incumbent of other"""
config1, config2 = self.best_config, other.best_config
incumbent1, incumbent2 = self.incumbent, other.incumbent
if self._resource and config1[self.prune_attr] > config2[self.prune_attr]:
@ -547,6 +576,9 @@ class FLOW2(Searcher):
if config1[key] != config2.get(key):
return False
delta = np.array(
[incumbent1[key] - incumbent2.get(key, np.inf)
for key in self._tunable_keys])
[
incumbent1[key] - incumbent2.get(key, np.inf)
for key in self._tunable_keys
]
)
return np.linalg.norm(delta) <= self.step

View File

@ -20,14 +20,19 @@ class BaseSearcher:
on_trial_complete()
"""
def __init__(self,
metric: Optional[str] = None,
mode: Optional[str] = None,
):
def __init__(
self,
metric: Optional[str] = None,
mode: Optional[str] = None,
):
pass
def set_search_properties(self, metric: Optional[str] = None, mode: Optional[str] = None,
config: Optional[Dict] = None):
def set_search_properties(
self,
metric: Optional[str] = None,
mode: Optional[str] = None,
config: Optional[Dict] = None,
):
if metric:
self._metric = metric
if mode:
@ -66,6 +71,7 @@ class ChampionFrontierSearcher(BaseSearcher):
(although not the same searcher_trial_id).
searcher_trial_id will be used in suggest()
"""
# ****the following constants are used when generating new challengers in
# the _query_config_oracle function
# how many item to add when doing the expansion
@ -84,25 +90,26 @@ class ChampionFrontierSearcher(BaseSearcher):
# 0.95 of the previous best config's loss.
# NOTE: this setting depends on the assumption that (and thus
# _query_config_oracle) is only triggered when a better champion is found.
CFO_SEARCHER_METRIC_NAME = 'pseudo_loss'
CFO_SEARCHER_METRIC_NAME = "pseudo_loss"
CFO_SEARCHER_LARGE_LOSS = 1e6
# the random seed used in generating numerical hyperparamter configs (when CFO is not used)
NUM_RANDOM_SEED = 111
CHAMPION_TRIAL_NAME = 'champion_trial'
CHAMPION_TRIAL_NAME = "champion_trial"
TRIAL_CLASS = VowpalWabbitTrial
def __init__(self,
init_config: Dict,
space: Optional[Dict] = None,
metric: Optional[str] = None,
mode: Optional[str] = None,
random_seed: Optional[int] = 2345,
online_trial_args: Optional[Dict] = {},
nonpoly_searcher_name: Optional[str] = 'CFO'
):
'''Constructor
def __init__(
self,
init_config: Dict,
space: Optional[Dict] = None,
metric: Optional[str] = None,
mode: Optional[str] = None,
random_seed: Optional[int] = 2345,
online_trial_args: Optional[Dict] = {},
nonpoly_searcher_name: Optional[str] = "CFO",
):
"""Constructor
Args:
init_config: dict
@ -113,7 +120,7 @@ class ChampionFrontierSearcher(BaseSearcher):
online_trial_args: dict
nonpoly_searcher_name: A string to specify the search algorithm
for nonpoly hyperparameters
'''
"""
self._init_config = init_config
self._space = space
self._seed = random_seed
@ -122,44 +129,62 @@ class ChampionFrontierSearcher(BaseSearcher):
self._random_state = np.random.RandomState(self._seed)
self._searcher_for_nonpoly_hp = {}
self._space_of_nonpoly_hp = {}
# dicts to remember the mapping between searcher_trial_id and trial_id
self._searcher_trialid_to_trialid = {} # key: searcher_trial_id, value: trial_id
self._trialid_to_searcher_trial_id = {} # value: trial_id, key: searcher_trial_id
self._space_of_nonpoly_hp = {}
# key: searcher_trial_id, value: trial_id
self._searcher_trialid_to_trialid = {}
# value: trial_id, key: searcher_trial_id
self._trialid_to_searcher_trial_id = {}
self._challenger_list = []
# initialize the search in set_search_properties
self.set_search_properties(config={self.CHAMPION_TRIAL_NAME: None}, init_call=True)
logger.debug('using random seed %s in config oracle', self._seed)
self.set_search_properties(
setting={self.CHAMPION_TRIAL_NAME: None}, init_call=True
)
logger.debug("using random seed %s in config oracle", self._seed)
def set_search_properties(self, metric: Optional[str] = None,
mode: Optional[str] = None,
config: Optional[Dict] = {},
init_call: Optional[bool] = False):
"""Construct search space with given config, and setup the search
"""
def set_search_properties(
self,
metric: Optional[str] = None,
mode: Optional[str] = None,
config: Optional[Dict] = {},
setting: Optional[Dict] = {},
init_call: Optional[bool] = False,
):
"""Construct search space with given config, and setup the search"""
super().set_search_properties(metric, mode, config)
# *********Use ConfigOralce (i.e, self._generate_new_space to generate list of new challengers)
logger.info('champion trial %s', config)
champion_trial = config.get(self.CHAMPION_TRIAL_NAME, None)
logger.info("setting %s", setting)
champion_trial = setting.get(self.CHAMPION_TRIAL_NAME, None)
if champion_trial is None:
champion_trial = self._create_trial_from_config(self._init_config)
# generate a new list of challenger trials
new_challenger_list = self._query_config_oracle(champion_trial.config,
champion_trial.trial_id,
self._trialid_to_searcher_trial_id[champion_trial.trial_id])
new_challenger_list = self._query_config_oracle(
champion_trial.config,
champion_trial.trial_id,
self._trialid_to_searcher_trial_id[champion_trial.trial_id],
)
# add the newly generated challengers to existing challengers
# there can be duplicates and we check duplicates when calling next_trial()
self._challenger_list = self._challenger_list + new_challenger_list
# add the champion as part of the new_challenger_list when called initially
if init_call:
self._challenger_list.append(champion_trial)
logger.critical('Created challengers from champion %s', champion_trial.trial_id)
logger.critical('New challenger size %s, %s', len(self._challenger_list),
[t.trial_id for t in self._challenger_list])
logger.info(
"**Important** Created challengers from champion %s",
champion_trial.trial_id,
)
logger.info(
"New challenger size %s, %s",
len(self._challenger_list),
[t.trial_id for t in self._challenger_list],
)
def next_trial(self):
"""Return a trial from the _challenger_list
"""
"""Return a trial from the _challenger_list"""
next_trial = None
if self._challenger_list:
next_trial = self._challenger_list.pop()
@ -175,8 +200,9 @@ class ChampionFrontierSearcher(BaseSearcher):
self._trialid_to_searcher_trial_id[trial.trial_id] = searcher_trial_id
return trial
def _query_config_oracle(self, seed_config, seed_config_trial_id,
seed_config_searcher_trial_id=None) -> List[Trial]:
def _query_config_oracle(
self, seed_config, seed_config_trial_id, seed_config_searcher_trial_id=None
) -> List[Trial]:
"""Give the seed config, generate a list of new configs (which are supposed to include
at least one config that has better performance than the input seed_config)
"""
@ -189,12 +215,16 @@ class ChampionFrontierSearcher(BaseSearcher):
config_domain = self._space[k]
if isinstance(config_domain, PolynomialExpansionSet):
# get candidate configs for hyperparameters of the PolynomialExpansionSet type
partial_new_configs = self._generate_independent_hp_configs(k, v, config_domain)
partial_new_configs = self._generate_independent_hp_configs(
k, v, config_domain
)
if partial_new_configs:
hyperparameter_config_groups.append(partial_new_configs)
# does not have searcher_trial_ids
searcher_trial_ids_groups.append([])
elif isinstance(config_domain, Float) or isinstance(config_domain, Categorical):
elif isinstance(config_domain, Float) or isinstance(
config_domain, Categorical
):
# otherwise we need to deal with them in group
nonpoly_config[k] = v
if k not in self._space_of_nonpoly_hp:
@ -204,38 +234,57 @@ class ChampionFrontierSearcher(BaseSearcher):
if nonpoly_config:
new_searcher_trial_ids = []
partial_new_nonpoly_configs = []
if 'CFO' in self._nonpoly_searcher_name:
if "CFO" in self._nonpoly_searcher_name:
if seed_config_trial_id not in self._searcher_for_nonpoly_hp:
self._searcher_for_nonpoly_hp[seed_config_trial_id] = CFO(space=self._space_of_nonpoly_hp,
points_to_evaluate=[nonpoly_config],
metric=self.CFO_SEARCHER_METRIC_NAME,
)
self._searcher_for_nonpoly_hp[seed_config_trial_id] = CFO(
space=self._space_of_nonpoly_hp,
points_to_evaluate=[nonpoly_config],
metric=self.CFO_SEARCHER_METRIC_NAME,
)
# initialize the search in set_search_properties
self._searcher_for_nonpoly_hp[seed_config_trial_id].set_search_properties(
config={'metric_target': self.CFO_SEARCHER_LARGE_LOSS})
self._searcher_for_nonpoly_hp[
seed_config_trial_id
].set_search_properties(
setting={"metric_target": self.CFO_SEARCHER_LARGE_LOSS}
)
# We need to call this for once, such that the seed config in points_to_evaluate will be called
# to be tried
self._searcher_for_nonpoly_hp[seed_config_trial_id].suggest(seed_config_searcher_trial_id)
self._searcher_for_nonpoly_hp[seed_config_trial_id].suggest(
seed_config_searcher_trial_id
)
# assuming minimization
if self._searcher_for_nonpoly_hp[seed_config_trial_id].metric_target is None:
if (
self._searcher_for_nonpoly_hp[seed_config_trial_id].metric_target
is None
):
pseudo_loss = self.CFO_SEARCHER_LARGE_LOSS
else:
pseudo_loss = self._searcher_for_nonpoly_hp[seed_config_trial_id].metric_target * 0.95
pseudo_loss = (
self._searcher_for_nonpoly_hp[
seed_config_trial_id
].metric_target
* 0.95
)
pseudo_result_to_report = {}
for k, v in nonpoly_config.items():
pseudo_result_to_report['config/' + str(k)] = v
pseudo_result_to_report["config/" + str(k)] = v
pseudo_result_to_report[self.CFO_SEARCHER_METRIC_NAME] = pseudo_loss
pseudo_result_to_report['time_total_s'] = 1
self._searcher_for_nonpoly_hp[seed_config_trial_id].on_trial_complete(seed_config_searcher_trial_id,
result=pseudo_result_to_report)
pseudo_result_to_report["time_total_s"] = 1
self._searcher_for_nonpoly_hp[seed_config_trial_id].on_trial_complete(
seed_config_searcher_trial_id, result=pseudo_result_to_report
)
while len(partial_new_nonpoly_configs) < self.NUMERICAL_NUM:
# suggest multiple times
new_searcher_trial_id = Trial.generate_id()
new_searcher_trial_ids.append(new_searcher_trial_id)
suggestion = self._searcher_for_nonpoly_hp[seed_config_trial_id].suggest(new_searcher_trial_id)
suggestion = self._searcher_for_nonpoly_hp[
seed_config_trial_id
].suggest(new_searcher_trial_id)
if suggestion is not None:
partial_new_nonpoly_configs.append(suggestion)
logger.info('partial_new_nonpoly_configs %s', partial_new_nonpoly_configs)
logger.info(
"partial_new_nonpoly_configs %s", partial_new_nonpoly_configs
)
else:
raise NotImplementedError
if partial_new_nonpoly_configs:
@ -244,9 +293,11 @@ class ChampionFrontierSearcher(BaseSearcher):
# ----------- coordinate generation of new challengers in the case of multiple groups
new_trials = []
for i in range(len(hyperparameter_config_groups)):
logger.info('hyperparameter_config_groups[i] %s %s',
len(hyperparameter_config_groups[i]),
hyperparameter_config_groups[i])
logger.info(
"hyperparameter_config_groups[i] %s %s",
len(hyperparameter_config_groups[i]),
hyperparameter_config_groups[i],
)
for j, new_partial_config in enumerate(hyperparameter_config_groups[i]):
new_seed_config = seed_config.copy()
new_seed_config.update(new_partial_config)
@ -260,32 +311,55 @@ class ChampionFrontierSearcher(BaseSearcher):
new_searcher_trial_id = searcher_trial_ids_groups[i][j]
else:
new_searcher_trial_id = None
new_trial = self._create_trial_from_config(new_seed_config, new_searcher_trial_id)
new_trial = self._create_trial_from_config(
new_seed_config, new_searcher_trial_id
)
new_trials.append(new_trial)
logger.info('new_configs %s', [t.trial_id for t in new_trials])
logger.info("new_configs %s", [t.trial_id for t in new_trials])
return new_trials
def _generate_independent_hp_configs(self, hp_name, current_config_value, config_domain) -> List:
def _generate_independent_hp_configs(
self, hp_name, current_config_value, config_domain
) -> List:
if isinstance(config_domain, PolynomialExpansionSet):
seed_interactions = list(current_config_value) + list(config_domain.init_monomials)
logger.critical('Seed namespaces (singletons and interactions): %s', seed_interactions)
logger.info('current_config_value %s %s', current_config_value, seed_interactions)
configs = self._generate_poly_expansion_sets(seed_interactions,
self.EXPANSION_ORDER,
config_domain.allow_self_inter,
config_domain.highest_poly_order,
self.POLY_EXPANSION_ADDITION_NUM,
)
seed_interactions = list(current_config_value) + list(
config_domain.init_monomials
)
logger.info(
"**Important** Seed namespaces (singletons and interactions): %s",
seed_interactions,
)
logger.info("current_config_value %s", current_config_value)
configs = self._generate_poly_expansion_sets(
seed_interactions,
self.EXPANSION_ORDER,
config_domain.allow_self_inter,
config_domain.highest_poly_order,
self.POLY_EXPANSION_ADDITION_NUM,
)
else:
raise NotImplementedError
configs_w_key = [{hp_name: hp_config} for hp_config in configs]
return configs_w_key
def _generate_poly_expansion_sets(self, seed_interactions, order, allow_self_inter,
highest_poly_order, interaction_num_to_add):
champion_all_combinations = self._generate_all_comb(seed_interactions, order, allow_self_inter, highest_poly_order)
space = sorted(list(itertools.combinations(
champion_all_combinations, interaction_num_to_add)))
def _generate_poly_expansion_sets(
self,
seed_interactions,
order,
allow_self_inter,
highest_poly_order,
interaction_num_to_add,
):
champion_all_combinations = self._generate_all_comb(
seed_interactions, order, allow_self_inter, highest_poly_order
)
space = sorted(
list(
itertools.combinations(
champion_all_combinations, interaction_num_to_add
)
)
)
self._random_state.shuffle(space)
candidate_configs = [set(seed_interactions) | set(item) for item in space]
final_candidate_configs = []
@ -295,9 +369,12 @@ class ChampionFrontierSearcher(BaseSearcher):
return final_candidate_configs
@staticmethod
def _generate_all_comb(seed_interactions: list, seed_interaction_order: int,
allow_self_inter: Optional[bool] = False,
highest_poly_order: Optional[int] = None):
def _generate_all_comb(
seed_interactions: list,
seed_interaction_order: int,
allow_self_inter: Optional[bool] = False,
highest_poly_order: Optional[int] = None,
):
"""Generate new interactions by doing up to seed_interaction_order on the seed_interactions
Args:
@ -312,8 +389,7 @@ class ChampionFrontierSearcher(BaseSearcher):
"""
def get_interactions(list1, list2):
"""Get combinatorial list of tuples
"""
"""Get combinatorial list of tuples"""
new_list = []
for i in list1:
for j in list2:
@ -321,19 +397,18 @@ class ChampionFrontierSearcher(BaseSearcher):
# 'abc' 'cba' 'bca' are all 'abc'
# this is done to ensure we can use the config as the signature
# of the trial, i.e., trial id.
new_interaction = ''.join(sorted(i + j))
new_interaction = "".join(sorted(i + j))
if new_interaction not in new_list:
new_list.append(new_interaction)
return new_list
def strip_self_inter(s):
"""Remove duplicates in an interaction string
"""
"""Remove duplicates in an interaction string"""
if len(s) == len(set(s)):
return s
else:
# return ''.join(sorted(set(s)))
new_s = ''
new_s = ""
char_list = []
for i in s:
if i not in char_list:
@ -351,10 +426,15 @@ class ChampionFrontierSearcher(BaseSearcher):
all_interactions_no_self_inter = []
for s in all_interactions:
s_no_inter = strip_self_inter(s)
if len(s_no_inter) > 1 and s_no_inter not in all_interactions_no_self_inter:
if (
len(s_no_inter) > 1
and s_no_inter not in all_interactions_no_self_inter
):
all_interactions_no_self_inter.append(s_no_inter)
all_interactions = all_interactions_no_self_inter
if highest_poly_order is not None:
all_interactions = [c for c in all_interactions if len(c) <= highest_poly_order]
logger.info('all_combinations %s', all_interactions)
all_interactions = [
c for c in all_interactions if len(c) <= highest_poly_order
]
logger.info("all_combinations %s", all_interactions)
return all_interactions

View File

@ -54,7 +54,7 @@ class SearchThread:
@classmethod
def set_eps(cls, time_budget_s):
cls._eps = max(min(time_budget_s / 1000.0, 1.0), 1e-10)
cls._eps = max(min(time_budget_s / 1000.0, 1.0), 1e-9)
def suggest(self, trial_id: str) -> Optional[Dict]:
''' use the suggest() of the underlying search algorithm

View File

@ -1,6 +1,6 @@
# Economical Hyperparameter Optimization
`flaml.tune` is a module for economical hyperparameter tuning. It frees users from manually tuning many hyperparameters for a software, such as machine learning training procedures.
`flaml.tune` is a module for economical hyperparameter tuning. It frees users from manually tuning many hyperparameters for a software, such as machine learning training procedures.
It can be used standalone, or together with ray tune or nni.
* Example for sequential tuning (recommended when compute resource is limited and each trial can consume all the resources):
@ -18,8 +18,8 @@ def evaluate_config(config):
# and the cost could be related to certain hyperparameters
# in this example, we assume it's proportional to x
time.sleep(config['x']/100000)
# use tune.report to report the metric to optimize
tune.report(metric=metric)
# use tune.report to report the metric to optimize
tune.report(metric=metric)
analysis = tune.run(
evaluate_config, # the function to evaluate a config
@ -33,7 +33,7 @@ analysis = tune.run(
num_samples=-1, # the maximal number of configs to try, -1 means infinite
time_budget_s=60, # the time budget in seconds
local_dir='logs/', # the local directory to store logs
# verbose=0, # verbosity
# verbose=0, # verbosity
# use_ray=True, # uncomment when performing parallel tuning using ray
)
@ -57,8 +57,8 @@ def evaluate_config(config):
# and the cost could be related to certain hyperparameters
# in this example, we assume it's proportional to x
time.sleep(config['x']/100000)
# use tune.report to report the metric to optimize
tune.report(metric=metric)
# use tune.report to report the metric to optimize
tune.report(metric=metric)
# provide a time budget (in seconds) for the tuning process
time_budget_s = 60
@ -77,25 +77,25 @@ cfo = CFO(low_cost_partial_config=low_cost_partial_config)
blendsearch = BlendSearch(
metric="metric", mode="min",
space=config_search_space,
low_cost_partial_config=low_cost_partial_config)
low_cost_partial_config=low_cost_partial_config,
time_budget_s=time_budget_s
)
# NOTE: when using BlendSearch as a search_alg in ray tune, you need to
# configure the 'time_budget_s' for BlendSearch accordingly as follows such that
# configure the 'time_budget_s' for BlendSearch accordingly such that
# BlendSearch is aware of the time budget. This step is not needed when
# BlendSearch is used as the search_alg in flaml.tune as it is already done
# automatically in flaml. Also, this step needs to be done after the search
# space is passed to BlendSearch and before raytune.run.
blendsearch.set_search_properties(config={"time_budget_s": time_budget_s})
# BlendSearch is used as the search_alg in flaml.tune as it is done
# automatically in flaml.
analysis = raytune.run(
evaluate_config, # the function to evaluate a config
config=config_search_space,
metric='metric', # the name of the metric used for optimization
mode='min', # the optimization mode, 'min' or 'max'
num_samples=-1, # the maximal number of configs to try, -1 means infinite
num_samples=-1, # the maximal number of configs to try, -1 means infinite
time_budget_s=time_budget_s, # the time budget in seconds
local_dir='logs/', # the local directory to store logs
search_alg=blendsearch # or cfo
)
)
print(analysis.best_trial.last_result) # the best trial's result
print(analysis.best_config) # the best config
@ -107,11 +107,10 @@ print(analysis.best_config) # the best config
$nnictl create --config ./config.yml
```
* For more examples, please check out
* For more examples, please check out
[notebooks](https://github.com/microsoft/FLAML/tree/main/notebook/).
`flaml` offers two HPO methods: CFO and BlendSearch.
`flaml` offers two HPO methods: CFO and BlendSearch.
`flaml.tune` uses BlendSearch by default.
## CFO: Frugal Optimization for Cost-related Hyperparameters
@ -121,27 +120,27 @@ $nnictl create --config ./config.yml
<br>
</p>
CFO uses the randomized direct search method FLOW<sup>2</sup> with adaptive stepsize and random restart.
CFO uses the randomized direct search method FLOW<sup>2</sup> with adaptive stepsize and random restart.
It requires a low-cost initial point as input if such point exists.
The search begins with the low-cost initial point and gradually move to
high cost region if needed. The local search method has a provable convergence
rate and bounded cost.
rate and bounded cost.
About FLOW<sup>2</sup>: FLOW<sup>2</sup> is a simple yet effective randomized direct search method.
About FLOW<sup>2</sup>: FLOW<sup>2</sup> is a simple yet effective randomized direct search method.
It is an iterative optimization method that can optimize for black-box functions.
FLOW<sup>2</sup> only requires pairwise comparisons between function values to perform iterative update. Comparing to existing HPO methods, FLOW<sup>2</sup> has the following appealing properties:
1. It is applicable to general black-box functions with a good convergence rate in terms of loss.
3. It provides theoretical guarantees on the total evaluation cost incurred.
1. It provides theoretical guarantees on the total evaluation cost incurred.
The GIFs attached below demonstrate an example search trajectory of FLOW<sup>2</sup> shown in the loss and evaluation cost (i.e., the training time ) space respectively. From the demonstration, we can see that (1) FLOW<sup>2</sup> can quickly move toward the low-loss region, showing good convergence property and (2) FLOW<sup>2</sup> tends to avoid exploring the high-cost region until necessary.
<p align="center">
<img align="center", src="https://github.com/microsoft/FLAML/blob/main/docs/images/heatmap_loss_cfo_12s.gif" width=360> <img align="center", src="https://github.com/microsoft/FLAML/blob/main/docs/images/heatmap_cost_cfo_12s.gif" width=360>
<img align="center", src="https://github.com/microsoft/FLAML/blob/main/docs/images/heatmap_loss_cfo_12s.gif" width=360> <img align="center", src="https://github.com/microsoft/FLAML/blob/main/docs/images/heatmap_cost_cfo_12s.gif" width=360>
<br>
<figcaption>Figure 1. FLOW<sup>2</sup> in tuning the # of leaves and the # of trees for XGBoost. The two background heatmaps show the loss and cost distribution of all configurations. The black dots are the points evaluated in FLOW<sup>2</sup>. Black dots connected by lines are points that yield better loss performance when evaluated.</figcaption>
</p>
Example:
```python
@ -152,9 +151,9 @@ tune.run(...
```
Recommended scenario: there exist cost-related hyperparameters and a low-cost
initial point is known before optimization.
initial point is known before optimization.
If the search space is complex and CFO gets trapped into local optima, consider
using BlendSearch.
using BlendSearch.
## BlendSearch: Economical Hyperparameter Optimization With Blended Search Strategy
@ -167,7 +166,7 @@ BlendSearch combines local search with global search. It leverages the frugality
of CFO and the space exploration ability of global search methods such as
Bayesian optimization. Like CFO, BlendSearch requires a low-cost initial point
as input if such point exists, and starts the search from there. Different from
CFO, BlendSearch will not wait for the local search to fully converge before
CFO, BlendSearch will not wait for the local search to fully converge before
trying new start points. The new start points are suggested by the global search
method and filtered based on their distance to the existing points in the
cost-related dimensions. BlendSearch still gradually increases the trial cost.
@ -184,19 +183,18 @@ tune.run(...
)
```
- Recommended scenario: cost-related hyperparameters exist, a low-cost
* Recommended scenario: cost-related hyperparameters exist, a low-cost
initial point is known, and the search space is complex such that local search
is prone to be stuck at local optima.
- Suggestion about using larger search space in BlendSearch:
* Suggestion about using larger search space in BlendSearch:
In hyperparameter optimization, a larger search space is desirable because it is more likely to include the optimal configuration (or one of the optimal configurations) in hindsight. However the performance (especially anytime performance) of most existing HPO methods is undesirable if the cost of the configurations in the search space has a large variation. Thus hand-crafted small search spaces (with relatively homogeneous cost) are often used in practice for these methods, which is subject to idiosyncrasy. BlendSearch combines the benefits of local search and global search, which enables a smart (economical) way of deciding where to explore in the search space even though it is larger than necessary. This allows users to specify a larger search space in BlendSearch, which is often easier and a better practice than narrowing down the search space by hand.
For more technical details, please check our papers.
* [Frugal Optimization for Cost-related Hyperparameters](https://arxiv.org/abs/2005.01571). Qingyun Wu, Chi Wang, Silu Huang. AAAI 2021.
```
```bibtex
@inproceedings{wu2021cfo,
title={Frugal Optimization for Cost-related Hyperparameters},
author={Qingyun Wu and Chi Wang and Silu Huang},
@ -207,11 +205,11 @@ For more technical details, please check our papers.
* [Economical Hyperparameter Optimization With Blended Search Strategy](https://www.microsoft.com/en-us/research/publication/economical-hyperparameter-optimization-with-blended-search-strategy/). Chi Wang, Qingyun Wu, Silu Huang, Amin Saied. ICLR 2021.
```
```bibtex
@inproceedings{wang2021blendsearch,
title={Economical Hyperparameter Optimization With Blended Search Strategy},
author={Chi Wang and Qingyun Wu and Silu Huang and Amin Saied},
year={2021},
booktitle={ICLR'21},
}
```
```

View File

@ -1,9 +1,10 @@
'''!
* Copyright (c) 2020-2021 Microsoft Corporation. All rights reserved.
"""!
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See LICENSE file in the
* project root for license information.
'''
"""
from typing import Optional
# try:
# from ray import __version__ as ray_version
# assert ray_version >= '1.0.0'
@ -11,20 +12,19 @@ from typing import Optional
# except (ImportError, AssertionError):
from .trial import Trial
import logging
logger = logging.getLogger(__name__)
class Nologger():
'''Logger without logging
'''
class Nologger:
"""Logger without logging"""
def on_result(self, result):
pass
class SimpleTrial(Trial):
'''A simple trial class
'''
"""A simple trial class"""
def __init__(self, config, trial_id=None):
self.trial_id = Trial.generate_id() if trial_id is None else trial_id
@ -49,10 +49,13 @@ class BaseTrialRunner:
Note that the caller usually should not mutate trial state directly.
"""
def __init__(self,
search_alg=None, scheduler=None,
metric: Optional[str] = None,
mode: Optional[str] = 'min'):
def __init__(
self,
search_alg=None,
scheduler=None,
metric: Optional[str] = None,
mode: Optional[str] = "min",
):
self._search_alg = search_alg
self._scheduler_alg = scheduler
self._trials = []
@ -89,12 +92,12 @@ class BaseTrialRunner:
trial.set_status(Trial.PAUSED)
def stop_trial(self, trial):
"""Stops trial.
"""
"""Stops trial."""
if trial.status not in [Trial.ERROR, Trial.TERMINATED]:
if self._scheduler_alg:
self._scheduler_alg.on_trial_complete(
self, trial.trial_id, trial.last_result)
self, trial.trial_id, trial.last_result
)
self._search_alg.on_trial_complete(trial.trial_id, trial.last_result)
trial.set_status(Trial.TERMINATED)
elif self._scheduler_alg:
@ -102,8 +105,7 @@ class BaseTrialRunner:
class SequentialTrialRunner(BaseTrialRunner):
"""Implementation of the sequential trial runner
"""
"""Implementation of the sequential trial runner"""
def step(self) -> Trial:
"""Runs one step of the trial event loop.
@ -114,7 +116,7 @@ class SequentialTrialRunner(BaseTrialRunner):
"""
trial_id = Trial.generate_id()
config = self._search_alg.suggest(trial_id)
if config:
if config is not None:
trial = SimpleTrial(config, trial_id)
self.add_trial(trial)
trial.set_status(Trial.RUNNING)

View File

@ -13,7 +13,10 @@ try:
assert ray_version >= "1.0.0"
from ray.tune.analysis import ExperimentAnalysis as EA
ray_import = True
except (ImportError, AssertionError):
ray_import = False
from .analysis import ExperimentAnalysis as EA
from .result import DEFAULT_METRIC
import logging
@ -278,9 +281,9 @@ def run(
else:
logger.setLevel(logging.CRITICAL)
if search_alg is None:
from ..searcher.blendsearch import BlendSearch
from ..searcher.blendsearch import BlendSearch
if search_alg is None:
search_alg = BlendSearch(
metric=metric or DEFAULT_METRIC,
mode=mode,
@ -299,16 +302,27 @@ def run(
metric_constraints=metric_constraints,
)
else:
search_alg.set_search_properties(metric, mode, config)
if metric is None or mode is None:
metric = metric or search_alg.metric
mode = mode or search_alg.mode
if time_budget_s or num_samples > 0:
search_alg.set_search_properties(
None,
None,
config={"time_budget_s": time_budget_s, "num_samples": num_samples},
)
if ray_import:
from ray.tune.suggest import ConcurrencyLimiter
else:
from flaml.searcher.suggestion import ConcurrencyLimiter
searcher = (
search_alg.searcher
if isinstance(search_alg, ConcurrencyLimiter)
else search_alg
)
if isinstance(searcher, BlendSearch):
setting = {}
if time_budget_s:
setting["time_budget_s"] = time_budget_s
if num_samples > 0:
setting["num_samples"] = num_samples
searcher.set_search_properties(metric, mode, config, setting)
else:
searcher.set_search_properties(metric, mode, config)
scheduler = None
if report_intermediate_result:
params = {}
@ -321,15 +335,10 @@ def run(
params["grace_period"] = min_resource
if reduction_factor:
params["reduction_factor"] = reduction_factor
try:
from ray import __version__ as ray_version
assert ray_version >= "1.0.0"
if ray_import:
from ray.tune.schedulers import ASHAScheduler
scheduler = ASHAScheduler(**params)
except (ImportError, AssertionError):
pass
if use_ray:
try:
from ray import tune
@ -392,7 +401,9 @@ def run(
else:
fail += 1 # break with ub consecutive failures
if fail == ub:
logger.warning("fail to sample a trial for 10 times in a row, stopping.")
logger.warning(
f"fail to sample a trial for {max_failure} times in a row, stopping."
)
if verbose > 0:
logger.handlers.clear()
return ExperimentAnalysis(_runner.get_trials(), metric=metric, mode=mode)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -18,7 +18,7 @@ install_requires = [
"lightgbm>=2.3.1",
"xgboost>=0.90,<=1.3.3",
"scipy>=1.4.1",
"catboost>=0.23",
# "catboost>=0.23", # making optional for conda
"scikit-learn>=0.24",
]
@ -47,6 +47,7 @@ setuptools.setup(
"coverage>=5.3",
"pre-commit",
"xgboost<1.3",
"catboost>=0.23",
"rgf-python",
"optuna==2.8.0",
"vowpalwabbit",
@ -58,6 +59,7 @@ setuptools.setup(
"azure-storage-blob",
"statsmodels>=0.12.2",
],
"catboost": ["catboost>=0.23"],
"blendsearch": ["optuna==2.8.0"],
"ray": [
"ray[tune]==1.6.0",

View File

@ -2,7 +2,12 @@ import unittest
import numpy as np
import scipy.sparse
from sklearn.datasets import load_boston, load_iris, load_wine, load_breast_cancer
from sklearn.datasets import (
fetch_california_housing,
load_iris,
load_wine,
load_breast_cancer,
)
import pandas as pd
from datetime import datetime
@ -17,59 +22,37 @@ from flaml.training_log import training_log_reader
class MyRegularizedGreedyForest(SKLearnEstimator):
def __init__(
self,
task="binary",
n_jobs=1,
max_leaf=4,
n_iter=1,
n_tree_search=1,
opt_interval=1,
learning_rate=1.0,
min_samples_leaf=1,
**params
):
def __init__(self, task="binary", **config):
super().__init__(task, **params)
super().__init__(task, **config)
if "regression" in task:
self.estimator_class = RGFRegressor
else:
if task in ("binary", "multi"):
self.estimator_class = RGFClassifier
# round integer hyperparameters
self.params = {
"n_jobs": n_jobs,
"max_leaf": int(round(max_leaf)),
"n_iter": int(round(n_iter)),
"n_tree_search": int(round(n_tree_search)),
"opt_interval": int(round(opt_interval)),
"learning_rate": learning_rate,
"min_samples_leaf": int(round(min_samples_leaf)),
}
else:
self.estimator_class = RGFRegressor
@classmethod
def search_space(cls, data_size, task):
space = {
"max_leaf": {
"domain": tune.qloguniform(lower=4, upper=data_size, q=1),
"domain": tune.lograndint(lower=4, upper=data_size),
"init_value": 4,
},
"n_iter": {
"domain": tune.qloguniform(lower=1, upper=data_size, q=1),
"domain": tune.lograndint(lower=1, upper=data_size),
"init_value": 1,
},
"n_tree_search": {
"domain": tune.qloguniform(lower=1, upper=32768, q=1),
"domain": tune.lograndint(lower=1, upper=32768),
"init_value": 1,
},
"opt_interval": {
"domain": tune.qloguniform(lower=1, upper=10000, q=1),
"domain": tune.lograndint(lower=1, upper=10000),
"init_value": 100,
},
"learning_rate": {"domain": tune.loguniform(lower=0.01, upper=20.0)},
"min_samples_leaf": {
"domain": tune.qloguniform(lower=1, upper=20, q=1),
"domain": tune.lograndint(lower=1, upper=20),
"init_value": 20,
},
}
@ -97,15 +80,15 @@ def logregobj(preds, dtrain):
class MyXGB1(XGBoostEstimator):
"""XGBoostEstimator with logregobj as the objective function"""
def __init__(self, **params):
super().__init__(objective=logregobj, **params)
def __init__(self, **config):
super().__init__(objective=logregobj, **config)
class MyXGB2(XGBoostEstimator):
"""XGBoostEstimator with 'reg:squarederror' as the objective function"""
def __init__(self, **params):
super().__init__(objective="reg:squarederror", **params)
def __init__(self, **config):
super().__init__(objective="reg:squarederror", **config)
class MyLargeLGBM(LGBMEstimator):
@ -266,7 +249,7 @@ class TestAutoML(unittest.TestCase):
"n_splits": 3,
"metric": "accuracy",
"log_training_metric": True,
"verbose": 1,
"verbose": 4,
"ensemble": True,
}
automl.fit(X, y, **automl_settings)
@ -281,7 +264,7 @@ class TestAutoML(unittest.TestCase):
"n_splits": 3,
"metric": "accuracy",
"log_training_metric": True,
"verbose": 1,
"verbose": 4,
"ensemble": True,
}
automl.fit(X, y, **automl_settings)
@ -296,7 +279,7 @@ class TestAutoML(unittest.TestCase):
"n_splits": 3,
"metric": "accuracy",
"log_training_metric": True,
"verbose": 1,
"verbose": 4,
"ensemble": True,
}
automl.fit(X, y, **automl_settings)
@ -311,7 +294,7 @@ class TestAutoML(unittest.TestCase):
"n_splits": 3,
"metric": "accuracy",
"log_training_metric": True,
"verbose": 1,
"verbose": 4,
"ensemble": True,
}
automl.fit(X, y, **automl_settings)
@ -525,7 +508,7 @@ class TestAutoML(unittest.TestCase):
"n_jobs": 1,
"model_history": True,
}
X_train, y_train = load_boston(return_X_y=True)
X_train, y_train = fetch_california_housing(return_X_y=True)
n = int(len(y_train) * 9 // 10)
automl_experiment.fit(
X_train=X_train[:n],
@ -648,7 +631,7 @@ class TestAutoML(unittest.TestCase):
"n_concurrent_trials": 2,
"hpo_method": hpo_method,
}
X_train, y_train = load_boston(return_X_y=True)
X_train, y_train = fetch_california_housing(return_X_y=True)
try:
automl_experiment.fit(X_train=X_train, y_train=y_train, **automl_settings)
print(automl_experiment.predict(X_train))
@ -861,8 +844,8 @@ class TestAutoML(unittest.TestCase):
automl_experiment = AutoML()
automl_settings = {
"time_budget": 3,
"metric": 'accuracy',
"task": 'classification',
"metric": "accuracy",
"task": "classification",
"log_file_name": "test/iris.log",
"log_training_metric": True,
"n_jobs": 1,
@ -873,16 +856,19 @@ class TestAutoML(unittest.TestCase):
# test drop column
X_train.columns = range(X_train.shape[1])
X_train[X_train.shape[1]] = np.zeros(len(y_train))
automl_experiment.fit(X_train=X_train, y_train=y_train,
**automl_settings)
automl_experiment.fit(X_train=X_train, y_train=y_train, **automl_settings)
automl_val_accuracy = 1.0 - automl_experiment.best_loss
print('Best ML leaner:', automl_experiment.best_estimator)
print('Best hyperparmeter config:', automl_experiment.best_config)
print('Best accuracy on validation data: {0:.4g}'.format(automl_val_accuracy))
print('Training duration of best run: {0:.4g} s'.format(automl_experiment.best_config_train_time))
print("Best ML leaner:", automl_experiment.best_estimator)
print("Best hyperparmeter config:", automl_experiment.best_config)
print("Best accuracy on validation data: {0:.4g}".format(automl_val_accuracy))
print(
"Training duration of best run: {0:.4g} s".format(
automl_experiment.best_config_train_time
)
)
starting_points = {}
log_file_name = automl_settings['log_file_name']
log_file_name = automl_settings["log_file_name"]
with training_log_reader(log_file_name) as reader:
for record in reader.records():
config = record.config
@ -893,25 +879,28 @@ class TestAutoML(unittest.TestCase):
max_iter = sum([len(s) for k, s in starting_points.items()])
automl_settings_resume = {
"time_budget": 2,
"metric": 'accuracy',
"task": 'classification',
"metric": "accuracy",
"task": "classification",
"log_file_name": "test/iris_resume_all.log",
"log_training_metric": True,
"n_jobs": 1,
"max_iter": max_iter,
"model_history": True,
"log_type": 'all',
"log_type": "all",
"starting_points": starting_points,
"append_log": True,
}
new_automl_experiment = AutoML()
new_automl_experiment.fit(X_train=X_train, y_train=y_train,
**automl_settings_resume)
new_automl_experiment.fit(
X_train=X_train, y_train=y_train, **automl_settings_resume
)
new_automl_val_accuracy = 1.0 - new_automl_experiment.best_loss
# print('Best ML leaner:', new_automl_experiment.best_estimator)
# print('Best hyperparmeter config:', new_automl_experiment.best_config)
print('Best accuracy on validation data: {0:.4g}'.format(new_automl_val_accuracy))
print(
"Best accuracy on validation data: {0:.4g}".format(new_automl_val_accuracy)
)
# print('Training duration of best run: {0:.4g} s'.format(new_automl_experiment.best_config_train_time))

View File

@ -1,6 +1,6 @@
from flaml.tune.space import unflatten_hierarchical
from flaml import AutoML
from sklearn.datasets import load_boston
from sklearn.datasets import fetch_california_housing
import os
import unittest
import logging
@ -9,7 +9,6 @@ import io
class TestLogging(unittest.TestCase):
def test_logging_level(self):
from flaml import logger, logger_formatter
@ -30,8 +29,8 @@ class TestLogging(unittest.TestCase):
automl = AutoML()
automl_settings = {
"time_budget": 1,
"metric": 'rmse',
"task": 'regression',
"metric": "rmse",
"task": "regression",
"log_file_name": training_log,
"log_training_metric": True,
"n_jobs": 1,
@ -39,35 +38,42 @@ class TestLogging(unittest.TestCase):
"keep_search_state": True,
"learner_selector": "roundrobin",
}
X_train, y_train = load_boston(return_X_y=True)
X_train, y_train = fetch_california_housing(return_X_y=True)
n = len(y_train) >> 1
print(automl.model, automl.classes_, automl.predict(X_train))
automl.fit(X_train=X_train[:n], y_train=y_train[:n],
X_val=X_train[n:], y_val=y_train[n:],
**automl_settings)
automl.fit(
X_train=X_train[:n],
y_train=y_train[:n],
X_val=X_train[n:],
y_val=y_train[n:],
**automl_settings
)
logger.info(automl.search_space)
logger.info(automl.low_cost_partial_config)
logger.info(automl.points_to_evaluate)
logger.info(automl.cat_hp_cost)
import optuna as ot
study = ot.create_study()
from flaml.tune.space import define_by_run_func, add_cost_to_space
sample = define_by_run_func(study.ask(), automl.search_space)
logger.info(sample)
logger.info(unflatten_hierarchical(sample, automl.search_space))
add_cost_to_space(
automl.search_space, automl.low_cost_partial_config,
automl.cat_hp_cost
automl.search_space, automl.low_cost_partial_config, automl.cat_hp_cost
)
logger.info(automl.search_space["ml"].categories)
config = automl.best_config.copy()
config['learner'] = automl.best_estimator
config["learner"] = automl.best_estimator
automl.trainable({"ml": config})
from flaml import tune, BlendSearch
from flaml.automl import size
from functools import partial
search_alg = BlendSearch(
metric='val_loss', mode='min',
metric="val_loss",
mode="min",
space=automl.search_space,
low_cost_partial_config=automl.low_cost_partial_config,
points_to_evaluate=automl.points_to_evaluate,
@ -75,19 +81,25 @@ class TestLogging(unittest.TestCase):
prune_attr=automl.prune_attr,
min_resource=automl.min_resource,
max_resource=automl.max_resource,
config_constraints=[(partial(size, automl._state), '<=', automl._mem_thres)],
metric_constraints=automl.metric_constraints)
config_constraints=[
(partial(size, automl._state), "<=", automl._mem_thres)
],
metric_constraints=automl.metric_constraints,
)
analysis = tune.run(
automl.trainable, search_alg=search_alg, # verbose=2,
time_budget_s=1, num_samples=-1)
print(min(trial.last_result["val_loss"]
for trial in analysis.trials))
config = analysis.trials[-1].last_result['config']['ml']
automl._state._train_with_config(config['learner'], config)
automl.trainable,
search_alg=search_alg, # verbose=2,
time_budget_s=1,
num_samples=-1,
)
print(min(trial.last_result["val_loss"] for trial in analysis.trials))
config = analysis.trials[-1].last_result["config"]["ml"]
automl._state._train_with_config(config["learner"], config)
# Check if the log buffer is populated.
self.assertTrue(len(buf.getvalue()) > 0)
import pickle
with open('automl.pkl', 'wb') as f:
with open("automl.pkl", "wb") as f:
pickle.dump(automl, f, pickle.HIGHEST_PROTOCOL)
print(automl.__version__)

View File

@ -2,15 +2,14 @@ import os
import unittest
from tempfile import TemporaryDirectory
from sklearn.datasets import load_boston
from sklearn.datasets import fetch_california_housing
from flaml import AutoML
from flaml.training_log import training_log_reader
class TestTrainingLog(unittest.TestCase):
def test_training_log(self, path='test_training_log.log'):
def test_training_log(self, path="test_training_log.log"):
with TemporaryDirectory() as d:
filename = os.path.join(d, path)
@ -19,8 +18,8 @@ class TestTrainingLog(unittest.TestCase):
automl = AutoML()
automl_settings = {
"time_budget": 1,
"metric": 'mse',
"task": 'regression',
"metric": "mse",
"task": "regression",
"log_file_name": filename,
"log_training_metric": True,
"mem_thres": 1024 * 1024,
@ -31,10 +30,9 @@ class TestTrainingLog(unittest.TestCase):
"ensemble": True,
"keep_search_state": True,
}
X_train, y_train = load_boston(return_X_y=True)
X_train, y_train = fetch_california_housing(return_X_y=True)
automl.fit(X_train=X_train, y_train=y_train, **automl_settings)
automl._state._train_with_config(
automl.best_estimator, automl.best_config)
automl._state._train_with_config(automl.best_estimator, automl.best_config)
# Check if the training log file is populated.
self.assertTrue(os.path.exists(filename))
@ -49,11 +47,11 @@ class TestTrainingLog(unittest.TestCase):
automl.fit(X_train=X_train, y_train=y_train, **automl_settings)
automl._selected.update(None, 0)
automl = AutoML()
automl.fit(X_train=X_train, y_train=y_train, max_iter=0)
automl.fit(X_train=X_train, y_train=y_train, max_iter=0, task="regression")
def test_illfilename(self):
try:
self.test_training_log('/')
self.test_training_log("/")
except IsADirectoryError:
print("IsADirectoryError happens as expected in linux.")
except PermissionError:

View File

@ -72,8 +72,9 @@ except (ImportError, AssertionError):
searcher = BlendSearch(
metric="m", global_search_alg=searcher, metric_constraints=[("c", "<", 1)]
)
searcher.set_search_properties(metric="m2", config=config)
searcher.set_search_properties(config={"time_budget_s": 0})
searcher.set_search_properties(
metric="m2", config=config, setting={"time_budget_s": 0}
)
c = searcher.suggest("t1")
searcher.on_trial_complete("t1", {"config": c}, True)
c = searcher.suggest("t2")
@ -146,3 +147,11 @@ except (ImportError, AssertionError):
print(searcher.suggest("t4"))
searcher.on_trial_complete({"t1"}, {})
searcher.on_trial_result({"t2"}, {})
np.random.seed(654321)
searcher = RandomSearch(
space=config,
points_to_evaluate=[{"a": 7, "b": 1e-3}, {"a": 6, "b": 3e-4}],
)
print(searcher.suggest("t1"))
print(searcher.suggest("t2"))
print(searcher.suggest("t3"))