298 lines
10 KiB
Python
298 lines
10 KiB
Python
import os
|
|
import pickle
|
|
import tempfile
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from typing import List
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
from ray import tune
|
|
from ray.air._internal.uri_utils import URI
|
|
from ray.air.constants import EXPR_PROGRESS_FILE, EXPR_RESULT_FILE
|
|
from ray.train._internal.storage import _delete_fs_path
|
|
from ray.train.tests.test_new_persistence import mock_s3_bucket_uri
|
|
from ray.train.tests.util import create_dict_checkpoint, load_dict_checkpoint
|
|
from ray.tune.analysis.experiment_analysis import ExperimentAnalysis
|
|
from ray.tune.experiment import Trial
|
|
from ray.tune.utils import flatten_dict
|
|
|
|
NUM_TRIALS = 3
|
|
NON_NAN_VALUE = 42
|
|
PEAK_VALUE = 100
|
|
|
|
|
|
def train_fn(config):
|
|
def report(metrics, should_checkpoint=True):
|
|
if should_checkpoint:
|
|
with create_dict_checkpoint(metrics) as checkpoint:
|
|
tune.report(metrics, checkpoint=checkpoint)
|
|
else:
|
|
tune.report(metrics)
|
|
|
|
id = config["id"]
|
|
|
|
report({"ascending": 1 * id, "peak": 0, "maybe_nan": np.nan, "iter": 1})
|
|
report({"ascending": 2 * id, "peak": 0, "maybe_nan": np.nan, "iter": 2})
|
|
report({"ascending": 3 * id, "peak": 0, "maybe_nan": np.nan, "iter": 3})
|
|
report(
|
|
{
|
|
"ascending": 4 * id,
|
|
"peak": 0,
|
|
"maybe_nan": NON_NAN_VALUE,
|
|
"iter": 4,
|
|
}
|
|
)
|
|
report({"ascending": 5 * id, "peak": PEAK_VALUE, "maybe_nan": np.nan, "iter": 5})
|
|
|
|
report(
|
|
{"ascending": 6 * id, "peak": -PEAK_VALUE, "maybe_nan": np.nan, "iter": 6},
|
|
should_checkpoint=False,
|
|
)
|
|
report(
|
|
{"ascending": 7 * id, "peak": 0, "maybe_nan": np.nan, "iter": 7},
|
|
should_checkpoint=False,
|
|
)
|
|
|
|
|
|
def _get_trial_with_id(trials: List[Trial], id: int) -> Trial:
|
|
return [trial for trial in trials if trial.config["id"] == id][0]
|
|
|
|
|
|
@contextmanager
|
|
def dummy_context_manager():
|
|
yield "dummy value"
|
|
|
|
|
|
@pytest.fixture(scope="module", params=["dir", "memory", "cloud"])
|
|
def experiment_analysis(request):
|
|
load_from = request.param
|
|
tmp_path = Path(tempfile.mkdtemp())
|
|
|
|
context_manager = (
|
|
mock_s3_bucket_uri if load_from == "cloud" else dummy_context_manager
|
|
)
|
|
|
|
with context_manager() as cloud_storage_path:
|
|
storage_path = (
|
|
str(cloud_storage_path)
|
|
if load_from == "cloud"
|
|
else str(tmp_path / "fake_nfs")
|
|
)
|
|
ea = tune.run(
|
|
train_fn,
|
|
config={"id": tune.grid_search(list(range(1, NUM_TRIALS + 1)))},
|
|
metric="ascending",
|
|
mode="max",
|
|
storage_path=storage_path,
|
|
name="test_experiment_analysis",
|
|
)
|
|
|
|
if load_from in ["dir", "cloud"]:
|
|
# Test init without passing in in-memory trials.
|
|
# Load them from an experiment directory instead.
|
|
yield ExperimentAnalysis(
|
|
str(URI(storage_path) / "test_experiment_analysis"),
|
|
default_metric="ascending",
|
|
default_mode="max",
|
|
)
|
|
elif load_from == "memory":
|
|
yield ea
|
|
else:
|
|
raise NotImplementedError(f"Invalid param: {load_from}")
|
|
|
|
|
|
@pytest.mark.parametrize("filetype", ["json", "csv"])
|
|
def test_fetch_trial_dataframes(experiment_analysis, filetype):
|
|
if filetype == "csv":
|
|
# Delete all json files so that we can test fallback to csv loading
|
|
for trial in experiment_analysis.trials:
|
|
_delete_fs_path(
|
|
fs=trial.storage.storage_filesystem,
|
|
fs_path=os.path.join(trial.storage.trial_fs_path, EXPR_RESULT_FILE),
|
|
)
|
|
else:
|
|
assert filetype == "json"
|
|
|
|
dfs = experiment_analysis._fetch_trial_dataframes()
|
|
assert len(dfs) == NUM_TRIALS
|
|
assert all(isinstance(df, pd.DataFrame) for df in dfs.values())
|
|
assert {trial.trial_id for trial in experiment_analysis.trials} == set(dfs)
|
|
|
|
for trial_id, df in dfs.items():
|
|
trial_config = experiment_analysis.get_all_configs()[trial_id]
|
|
assert np.all(
|
|
df["ascending"].to_numpy() == np.arange(1, 8) * trial_config["id"]
|
|
)
|
|
|
|
|
|
def test_fetch_trial_dataframes_with_errors(
|
|
experiment_analysis, tmp_path, propagate_logs, caplog
|
|
):
|
|
# Add "corrupted" json files)
|
|
for trial in experiment_analysis.trials:
|
|
fs = trial.storage.storage_filesystem
|
|
with fs.open_output_stream(
|
|
os.path.join(trial.storage.trial_fs_path, EXPR_RESULT_FILE)
|
|
) as f:
|
|
f.write(b"malformed")
|
|
|
|
experiment_analysis._fetch_trial_dataframes()
|
|
assert "Failed to fetch metrics" in caplog.text
|
|
caplog.clear()
|
|
|
|
# Delete ALL metrics files to check that a warning gets logged.
|
|
for trial in experiment_analysis.trials:
|
|
fs = trial.storage.storage_filesystem
|
|
|
|
# Delete ALL metrics files to check that a warning gets logged.
|
|
_delete_fs_path(
|
|
fs=trial.storage.storage_filesystem,
|
|
fs_path=os.path.join(trial.storage.trial_fs_path, EXPR_RESULT_FILE),
|
|
)
|
|
_delete_fs_path(
|
|
fs=trial.storage.storage_filesystem,
|
|
fs_path=os.path.join(trial.storage.trial_fs_path, EXPR_PROGRESS_FILE),
|
|
)
|
|
|
|
experiment_analysis._fetch_trial_dataframes()
|
|
assert "Could not fetch metrics for" in caplog.text
|
|
assert "FileNotFoundError" in caplog.text
|
|
caplog.clear()
|
|
|
|
|
|
def test_get_all_configs(experiment_analysis):
|
|
configs = experiment_analysis.get_all_configs()
|
|
assert len(configs) == NUM_TRIALS
|
|
assert all(isinstance(config, dict) for config in configs.values())
|
|
assert {trial.trial_id for trial in experiment_analysis.trials} == set(configs)
|
|
for trial_id, config in configs.items():
|
|
trial = [
|
|
trial for trial in experiment_analysis.trials if trial.trial_id == trial_id
|
|
][0]
|
|
assert trial.config == config
|
|
|
|
|
|
def test_dataframe(experiment_analysis):
|
|
with pytest.raises(ValueError):
|
|
# Invalid mode
|
|
df = experiment_analysis.dataframe(mode="bad")
|
|
|
|
with pytest.raises(ValueError):
|
|
# Should raise because we didn't pass a metric
|
|
df = experiment_analysis.dataframe(mode="max")
|
|
|
|
# If we specify `max`, we expect the largets ever observed result
|
|
df = experiment_analysis.dataframe(metric="peak", mode="max")
|
|
assert df.iloc[0]["peak"] == PEAK_VALUE
|
|
|
|
# If we specify `min`, we expect the lowest ever observed result
|
|
df = experiment_analysis.dataframe(metric="peak", mode="min")
|
|
assert df.iloc[0]["peak"] == -PEAK_VALUE
|
|
|
|
# If we don't pass a mode, we just fetch the last result
|
|
df = experiment_analysis.dataframe(metric="peak")
|
|
assert df.iloc[0]["peak"] == 0
|
|
assert df.iloc[0]["iter"] == 7
|
|
|
|
|
|
def test_default_properties(experiment_analysis):
|
|
# The last trial has the highest score (according to the default metric/mode).
|
|
best_trial = _get_trial_with_id(experiment_analysis.trials, NUM_TRIALS)
|
|
assert experiment_analysis.best_trial == best_trial
|
|
assert experiment_analysis.best_config == best_trial.config
|
|
# The last (most recent) checkpoint has the highest score.
|
|
assert experiment_analysis.best_checkpoint == best_trial.checkpoint
|
|
# NaN != NaN, so fill them in for this equality check.
|
|
assert experiment_analysis.best_dataframe.fillna(-1).equals(
|
|
experiment_analysis.trial_dataframes[best_trial.trial_id].fillna(-1)
|
|
)
|
|
assert experiment_analysis.best_result == best_trial.last_result
|
|
|
|
result_df_dict = experiment_analysis.best_result_df.iloc[0].to_dict()
|
|
# Converting -> pandas -> dict flattens the dict.
|
|
best_trial_dict = flatten_dict(best_trial.last_result, delimiter="/")
|
|
assert result_df_dict["ascending"] == best_trial_dict["ascending"]
|
|
|
|
assert len(experiment_analysis.results) == NUM_TRIALS
|
|
assert len(experiment_analysis.results_df) == NUM_TRIALS
|
|
|
|
|
|
def test_get_best_config(experiment_analysis):
|
|
assert experiment_analysis.get_best_config()["id"] == NUM_TRIALS
|
|
assert (
|
|
experiment_analysis.get_best_config(metric="ascending", mode="min")["id"] == 1
|
|
)
|
|
|
|
assert not experiment_analysis.get_best_config(metric="maybe_nan", scope="last")
|
|
|
|
|
|
def test_get_best_trial(experiment_analysis):
|
|
assert (
|
|
experiment_analysis.get_best_trial().config
|
|
== experiment_analysis.get_best_config()
|
|
)
|
|
|
|
assert not experiment_analysis.get_best_trial(metric="maybe_nan")
|
|
assert experiment_analysis.get_best_trial(
|
|
metric="maybe_nan", filter_nan_and_inf=False
|
|
)
|
|
|
|
|
|
def test_get_best_checkpoint(experiment_analysis):
|
|
best_trial = experiment_analysis.get_best_trial()
|
|
best_checkpoint = load_dict_checkpoint(
|
|
experiment_analysis.get_best_checkpoint(best_trial)
|
|
)
|
|
# NOTE: There are 7 reports, but only the first 5 include a checkpoint.
|
|
assert best_checkpoint["ascending"] == 5 * NUM_TRIALS
|
|
|
|
best_checkpoint = load_dict_checkpoint(
|
|
experiment_analysis.get_best_checkpoint(
|
|
best_trial, metric="ascending", mode="min"
|
|
)
|
|
)
|
|
assert best_checkpoint["ascending"] == 1 * NUM_TRIALS
|
|
|
|
# Filter checkpoints w/ NaN metrics
|
|
best_checkpoint = load_dict_checkpoint(
|
|
experiment_analysis.get_best_checkpoint(best_trial, metric="maybe_nan")
|
|
)
|
|
assert best_checkpoint["maybe_nan"] == NON_NAN_VALUE
|
|
|
|
|
|
def test_get_last_checkpoint(experiment_analysis):
|
|
# Defaults to getting the last checkpoint of the best trial.
|
|
last_checkpoint = load_dict_checkpoint(experiment_analysis.get_last_checkpoint())
|
|
assert last_checkpoint["iter"] == 5 # See note
|
|
|
|
last_checkpoint = load_dict_checkpoint(
|
|
experiment_analysis.get_last_checkpoint(
|
|
trial=_get_trial_with_id(experiment_analysis.trials, 1)
|
|
)
|
|
)
|
|
assert last_checkpoint["ascending"] == 5 * 1 # See note
|
|
|
|
|
|
def test_pickle(experiment_analysis, tmp_path):
|
|
pickle_path = os.path.join(tmp_path, "analysis.pkl")
|
|
with open(pickle_path, "wb") as f:
|
|
pickle.dump(experiment_analysis, f)
|
|
|
|
assert experiment_analysis.get_best_trial(metric="ascending", mode="max")
|
|
|
|
with open(pickle_path, "rb") as f:
|
|
loaded_analysis = pickle.load(f)
|
|
|
|
assert loaded_analysis.get_best_trial(metric="ascending", mode="max")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
sys.exit(pytest.main(["-v", __file__]))
|