443 lines
15 KiB
Python
443 lines
15 KiB
Python
import os
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from collections import namedtuple
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from mlflow.tracking import MlflowClient
|
|
|
|
import ray
|
|
from ray._private.dict import flatten_dict
|
|
from ray.air._internal.mlflow import _MLflowLoggerUtil
|
|
from ray.air.integrations.mlflow import MLflowLoggerCallback, _NoopModule, setup_mlflow
|
|
from ray.train.torch import TorchTrainer
|
|
from ray.tune import Tuner
|
|
|
|
|
|
class MockTrial(
|
|
namedtuple("MockTrial", ["config", "trial_name", "trial_id", "local_path"])
|
|
):
|
|
def __hash__(self):
|
|
return hash(self.trial_id)
|
|
|
|
def __str__(self):
|
|
return self.trial_name
|
|
|
|
|
|
class Mock_MLflowLoggerUtil(_MLflowLoggerUtil):
|
|
def save_artifacts(self, dir, run_id):
|
|
self.artifact_saved = True
|
|
self.artifact_info = {"dir": dir, "run_id": run_id}
|
|
|
|
|
|
def clear_env_vars():
|
|
os.environ.pop("MLFLOW_EXPERIMENT_NAME", None)
|
|
os.environ.pop("MLFLOW_EXPERIMENT_ID", None)
|
|
|
|
|
|
@pytest.fixture
|
|
def ray_start_4_cpus():
|
|
"""Automatically start and stop Ray for each test."""
|
|
ray.init(num_cpus=4)
|
|
yield
|
|
ray.shutdown()
|
|
|
|
|
|
def test_setup_mlflow_in_train_worker(ray_start_4_cpus):
|
|
"""Test that setup_mlflow works in a Train worker."""
|
|
|
|
def train_func(config):
|
|
setup_mlflow(
|
|
experiment_name="test_exp",
|
|
create_experiment_if_not_exists=True,
|
|
)
|
|
|
|
trainer = TorchTrainer(train_func)
|
|
trainer.fit()
|
|
|
|
|
|
def test_setup_mlflow_in_tune_trial(ray_start_4_cpus):
|
|
"""Test that setup_mlflow works in a Tune trial."""
|
|
|
|
def train_func(config):
|
|
setup_mlflow(
|
|
experiment_name="test_exp",
|
|
create_experiment_if_not_exists=True,
|
|
)
|
|
|
|
tuner = Tuner(train_func)
|
|
result_grid = tuner.fit()
|
|
|
|
assert all(res.error is None for res in result_grid)
|
|
|
|
|
|
class MLflowTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tracking_uri = "sqlite:///" + tempfile.mkdtemp() + "/mlflow.sqlite"
|
|
self.registry_uri = "sqlite:///" + tempfile.mkdtemp() + "/mlflow.sqlite"
|
|
|
|
client = MlflowClient(
|
|
tracking_uri=self.tracking_uri, registry_uri=self.registry_uri
|
|
)
|
|
client.create_experiment(name="existing_experiment")
|
|
# Mlflow > 2 creates a "Default" experiment which has ID 0, so we start our
|
|
# test with ID 1.
|
|
assert client.get_experiment_by_name("existing_experiment").experiment_id == "1"
|
|
|
|
def tearDown(self) -> None:
|
|
pass
|
|
|
|
def testMlFlowLoggerCallbackConfig(self):
|
|
# Explicitly pass in all args.
|
|
logger = MLflowLoggerCallback(
|
|
tracking_uri=self.tracking_uri,
|
|
registry_uri=self.registry_uri,
|
|
experiment_name="test_exp",
|
|
)
|
|
logger.setup()
|
|
self.assertEqual(
|
|
logger.mlflow_util._mlflow.get_tracking_uri(), self.tracking_uri
|
|
)
|
|
self.assertEqual(
|
|
logger.mlflow_util._mlflow.get_registry_uri(), self.registry_uri
|
|
)
|
|
self.assertListEqual(
|
|
[e.name for e in logger.mlflow_util._mlflow.search_experiments()],
|
|
["test_exp", "existing_experiment", "Default"],
|
|
)
|
|
self.assertEqual(logger.mlflow_util.experiment_id, "2")
|
|
|
|
# Check if client recognizes already existing experiment.
|
|
logger = MLflowLoggerCallback(
|
|
experiment_name="existing_experiment",
|
|
tracking_uri=self.tracking_uri,
|
|
registry_uri=self.registry_uri,
|
|
)
|
|
logger.setup()
|
|
self.assertEqual(logger.mlflow_util.experiment_id, "1")
|
|
|
|
# Pass in experiment name as env var.
|
|
clear_env_vars()
|
|
os.environ["MLFLOW_EXPERIMENT_NAME"] = "test_exp"
|
|
logger = MLflowLoggerCallback(
|
|
tracking_uri=self.tracking_uri, registry_uri=self.registry_uri
|
|
)
|
|
logger.setup()
|
|
self.assertEqual(logger.mlflow_util.experiment_id, "2")
|
|
|
|
# Pass in existing experiment name as env var.
|
|
clear_env_vars()
|
|
os.environ["MLFLOW_EXPERIMENT_NAME"] = "existing_experiment"
|
|
logger = MLflowLoggerCallback(
|
|
tracking_uri=self.tracking_uri, registry_uri=self.registry_uri
|
|
)
|
|
logger.setup()
|
|
self.assertEqual(logger.mlflow_util.experiment_id, "1")
|
|
|
|
# Pass in existing experiment id as env var.
|
|
clear_env_vars()
|
|
os.environ["MLFLOW_EXPERIMENT_ID"] = "1"
|
|
logger = MLflowLoggerCallback(
|
|
tracking_uri=self.tracking_uri, registry_uri=self.registry_uri
|
|
)
|
|
logger.setup()
|
|
self.assertEqual(logger.mlflow_util.experiment_id, "1")
|
|
|
|
# Pass in non existing experiment id as env var.
|
|
# This should create a new experiment.
|
|
clear_env_vars()
|
|
os.environ["MLFLOW_EXPERIMENT_ID"] = "500"
|
|
with self.assertRaises(ValueError):
|
|
logger = MLflowLoggerCallback(
|
|
tracking_uri=self.tracking_uri, registry_uri=self.registry_uri
|
|
)
|
|
logger.setup()
|
|
|
|
# Experiment id env var should take precedence over name env var.
|
|
clear_env_vars()
|
|
os.environ["MLFLOW_EXPERIMENT_NAME"] = "test_exp"
|
|
os.environ["MLFLOW_EXPERIMENT_ID"] = "1"
|
|
logger = MLflowLoggerCallback(
|
|
tracking_uri=self.tracking_uri, registry_uri=self.registry_uri
|
|
)
|
|
logger.setup()
|
|
self.assertEqual(logger.mlflow_util.experiment_id, "1")
|
|
|
|
# Using tags
|
|
tags = {"user_name": "John", "git_commit_hash": "abc123"}
|
|
clear_env_vars()
|
|
os.environ["MLFLOW_EXPERIMENT_NAME"] = "test_tags"
|
|
os.environ["MLFLOW_EXPERIMENT_ID"] = "1"
|
|
logger = MLflowLoggerCallback(
|
|
tracking_uri=self.tracking_uri, registry_uri=self.registry_uri, tags=tags
|
|
)
|
|
logger.setup()
|
|
self.assertEqual(logger.tags, tags)
|
|
|
|
@patch("ray.air.integrations.mlflow._MLflowLoggerUtil", Mock_MLflowLoggerUtil)
|
|
def testMlFlowLoggerLogging(self):
|
|
clear_env_vars()
|
|
trial_config = {"par1": "a", "par2": "b"}
|
|
trial = MockTrial(trial_config, "trial1", 0, "artifact")
|
|
|
|
logger = MLflowLoggerCallback(
|
|
tracking_uri=self.tracking_uri,
|
|
registry_uri=self.registry_uri,
|
|
experiment_name="test1",
|
|
save_artifact=True,
|
|
tags={"hello": "world"},
|
|
)
|
|
logger.setup()
|
|
|
|
# Check if run is created with proper tags.
|
|
logger.on_trial_start(iteration=0, trials=[], trial=trial)
|
|
all_runs = logger.mlflow_util._mlflow.search_runs(experiment_ids=["2"])
|
|
self.assertEqual(len(all_runs), 1)
|
|
# all_runs is a pandas dataframe.
|
|
all_runs = all_runs.to_dict(orient="records")
|
|
run = logger.mlflow_util._mlflow.get_run(all_runs[0]["run_id"])
|
|
self.assertDictEqual(
|
|
run.data.tags,
|
|
{"hello": "world", "trial_name": "trial1", "mlflow.runName": "trial1"},
|
|
)
|
|
self.assertEqual(logger._trial_runs[trial], run.info.run_id)
|
|
# Params should be logged.
|
|
self.assertDictEqual(run.data.params, trial_config)
|
|
|
|
# When same trial is started again, new run should not be created.
|
|
logger.on_trial_start(iteration=0, trials=[], trial=trial)
|
|
all_runs = logger.mlflow_util._mlflow.search_runs(experiment_ids=["2"])
|
|
self.assertEqual(len(all_runs), 1)
|
|
|
|
# Check metrics are logged properly.
|
|
result = {
|
|
"metric1": 0.8,
|
|
"metric2": 1,
|
|
"metric3": None,
|
|
"training_iteration": 0,
|
|
}
|
|
logger.on_trial_result(0, [], trial, result)
|
|
run = logger.mlflow_util._mlflow.get_run(run_id=run.info.run_id)
|
|
# metric3 is not logged since it cannot be converted to float.
|
|
self.assertDictEqual(
|
|
run.data.metrics, {"metric1": 0.8, "metric2": 1.0, "training_iteration": 0}
|
|
)
|
|
|
|
# Check that artifact is logged on termination.
|
|
logger.on_trial_complete(0, [], trial)
|
|
self.assertTrue(logger.mlflow_util.artifact_saved)
|
|
self.assertDictEqual(
|
|
logger.mlflow_util.artifact_info,
|
|
{"dir": "artifact", "run_id": run.info.run_id},
|
|
)
|
|
|
|
# Check if params are logged at the end.
|
|
run = logger.mlflow_util._mlflow.get_run(run_id=run.info.run_id)
|
|
self.assertDictEqual(run.data.params, trial_config)
|
|
|
|
@patch("ray.air.integrations.mlflow._MLflowLoggerUtil", Mock_MLflowLoggerUtil)
|
|
def testMlFlowLoggerLogging_logAtEnd(self):
|
|
clear_env_vars()
|
|
trial_config = {"par1": "a", "par2": "b"}
|
|
trial = MockTrial(trial_config, "trial1", 0, "artifact")
|
|
|
|
logger = MLflowLoggerCallback(
|
|
tracking_uri=self.tracking_uri,
|
|
registry_uri=self.registry_uri,
|
|
experiment_name="test_log_at_end",
|
|
tags={"hello": "world"},
|
|
log_params_on_trial_end=True,
|
|
)
|
|
logger.setup()
|
|
exp_id = logger.mlflow_util.experiment_id
|
|
|
|
logger.on_trial_start(iteration=0, trials=[], trial=trial)
|
|
all_runs = logger.mlflow_util._mlflow.search_runs(experiment_ids=[exp_id])
|
|
self.assertEqual(len(all_runs), 1)
|
|
# all_runs is a pandas dataframe.
|
|
all_runs = all_runs.to_dict(orient="records")
|
|
run = logger.mlflow_util._mlflow.get_run(all_runs[0]["run_id"])
|
|
|
|
# Params should NOT be logged at start.
|
|
self.assertDictEqual(run.data.params, {})
|
|
|
|
# Check that params are logged at the end.
|
|
logger.on_trial_complete(0, [], trial)
|
|
run = logger.mlflow_util._mlflow.get_run(run_id=run.info.run_id)
|
|
self.assertDictEqual(run.data.params, trial_config)
|
|
|
|
def testMlFlowSetupExplicit(self):
|
|
clear_env_vars()
|
|
trial_config = {"par1": 4, "par2": 9.0}
|
|
|
|
# No MLflow config passed in.
|
|
with self.assertRaises(ValueError):
|
|
setup_mlflow(trial_config)
|
|
|
|
# Invalid experiment-id
|
|
with self.assertRaises(ValueError):
|
|
setup_mlflow(trial_config, experiment_id="500")
|
|
|
|
# Set to experiment that does not already exist.
|
|
with self.assertRaises(ValueError):
|
|
setup_mlflow(
|
|
trial_config,
|
|
experiment_id="500",
|
|
experiment_name="new_experiment",
|
|
tracking_uri=self.tracking_uri,
|
|
)
|
|
|
|
mlflow = setup_mlflow(
|
|
trial_config,
|
|
experiment_id="500",
|
|
experiment_name="existing_experiment",
|
|
tracking_uri=self.tracking_uri,
|
|
)
|
|
mlflow.end_run()
|
|
|
|
@patch("ray.train.get_context")
|
|
def testMlFlowSetupRankNonRankZero(self, mock_get_context):
|
|
"""Assert that non-rank-0 workers get a noop module"""
|
|
mock_context = MagicMock()
|
|
mock_context.get_world_rank.return_value = 1
|
|
|
|
mock_get_context.return_value = mock_context
|
|
|
|
mlflow = setup_mlflow({})
|
|
assert isinstance(mlflow, _NoopModule)
|
|
|
|
mlflow.log_metrics()
|
|
mlflow.sklearn.save_model(None, "model_directory")
|
|
|
|
|
|
class MLflowUtilTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.dirpath = tempfile.mkdtemp()
|
|
import mlflow
|
|
|
|
mlflow.set_tracking_uri("sqlite:///" + self.dirpath + "/mlflow.sqlite")
|
|
mlflow.create_experiment(name="existing_experiment")
|
|
|
|
self.mlflow_util = _MLflowLoggerUtil()
|
|
self.tracking_uri = mlflow.get_tracking_uri()
|
|
|
|
def tearDown(self):
|
|
shutil.rmtree(self.dirpath)
|
|
|
|
def test_experiment_id(self):
|
|
self.mlflow_util.setup_mlflow(tracking_uri=self.tracking_uri, experiment_id="0")
|
|
assert self.mlflow_util.experiment_id == "0"
|
|
|
|
def test_experiment_id_env_var(self):
|
|
os.environ["MLFLOW_EXPERIMENT_ID"] = "0"
|
|
self.mlflow_util.setup_mlflow(tracking_uri=self.tracking_uri)
|
|
assert self.mlflow_util.experiment_id == "0"
|
|
del os.environ["MLFLOW_EXPERIMENT_ID"]
|
|
|
|
def test_experiment_name(self):
|
|
self.mlflow_util.setup_mlflow(
|
|
tracking_uri=self.tracking_uri, experiment_name="existing_experiment"
|
|
)
|
|
assert self.mlflow_util.experiment_id == "1"
|
|
|
|
def test_run_started_with_correct_experiment(self):
|
|
experiment_name = "my_experiment_name"
|
|
# Make sure run is started under the correct experiment.
|
|
self.mlflow_util.setup_mlflow(
|
|
tracking_uri=self.tracking_uri, experiment_name=experiment_name
|
|
)
|
|
run = self.mlflow_util.start_run(set_active=True)
|
|
assert (
|
|
run.info.experiment_id
|
|
== self.mlflow_util._mlflow.get_experiment_by_name(
|
|
experiment_name
|
|
).experiment_id
|
|
)
|
|
|
|
self.mlflow_util.end_run()
|
|
|
|
def test_experiment_name_env_var(self):
|
|
os.environ["MLFLOW_EXPERIMENT_NAME"] = "existing_experiment"
|
|
self.mlflow_util.setup_mlflow(tracking_uri=self.tracking_uri)
|
|
assert self.mlflow_util.experiment_id == "1"
|
|
del os.environ["MLFLOW_EXPERIMENT_NAME"]
|
|
|
|
def test_id_precedence(self):
|
|
os.environ["MLFLOW_EXPERIMENT_ID"] = "0"
|
|
self.mlflow_util.setup_mlflow(
|
|
tracking_uri=self.tracking_uri, experiment_name="new_experiment"
|
|
)
|
|
assert self.mlflow_util.experiment_id == "0"
|
|
del os.environ["MLFLOW_EXPERIMENT_ID"]
|
|
|
|
def test_new_experiment(self):
|
|
self.mlflow_util.setup_mlflow(
|
|
tracking_uri=self.tracking_uri, experiment_name="new_experiment"
|
|
)
|
|
assert self.mlflow_util.experiment_id == "2"
|
|
|
|
def test_setup_fail(self):
|
|
with self.assertRaises(ValueError):
|
|
self.mlflow_util.setup_mlflow(
|
|
tracking_uri=self.tracking_uri,
|
|
experiment_name="new_experiment2",
|
|
create_experiment_if_not_exists=False,
|
|
)
|
|
|
|
def test_log_params(self):
|
|
params = {"a": "a", "x": {"y": "z"}}
|
|
self.mlflow_util.setup_mlflow(
|
|
tracking_uri=self.tracking_uri, experiment_name="new_experiment"
|
|
)
|
|
run = self.mlflow_util.start_run()
|
|
run_id = run.info.run_id
|
|
self.mlflow_util.log_params(params_to_log=params, run_id=run_id)
|
|
|
|
run = self.mlflow_util._mlflow.get_run(run_id=run_id)
|
|
assert run.data.params == flatten_dict(params)
|
|
|
|
params2 = {"b": "b"}
|
|
self.mlflow_util.start_run(set_active=True)
|
|
self.mlflow_util.log_params(params_to_log=params2, run_id=run_id)
|
|
run = self.mlflow_util._mlflow.get_run(run_id=run_id)
|
|
assert run.data.params == flatten_dict(
|
|
{
|
|
**params,
|
|
**params2,
|
|
}
|
|
)
|
|
|
|
self.mlflow_util.end_run()
|
|
|
|
def test_log_metrics(self):
|
|
metrics = {"a": 1.0, "x": {"y": 2.0}}
|
|
self.mlflow_util.setup_mlflow(
|
|
tracking_uri=self.tracking_uri, experiment_name="new_experiment"
|
|
)
|
|
run = self.mlflow_util.start_run()
|
|
run_id = run.info.run_id
|
|
self.mlflow_util.log_metrics(metrics_to_log=metrics, run_id=run_id, step=0)
|
|
|
|
run = self.mlflow_util._mlflow.get_run(run_id=run_id)
|
|
assert run.data.metrics == flatten_dict(metrics)
|
|
|
|
metrics2 = {"b": 1.0}
|
|
self.mlflow_util.start_run(set_active=True)
|
|
self.mlflow_util.log_metrics(metrics_to_log=metrics2, run_id=run_id, step=0)
|
|
assert self.mlflow_util._mlflow.get_run(
|
|
run_id=run_id
|
|
).data.metrics == flatten_dict(
|
|
{
|
|
**metrics,
|
|
**metrics2,
|
|
}
|
|
)
|
|
self.mlflow_util.end_run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-v", __file__]))
|