Repository: mlflow/mlflow

Issue:
[FR] Ability to configure run_name along with experiment_name in MLflow Recipes
### Willingness to contribute

Yes. I can contribute this feature independently.

### Proposal Summary

Currently there is a support to configure experiment_name where a new run is created. It would be great if we could configure the run_name as well. Might not work with FLAML.  

### Motivation

> #### What is the use case for this feature?
Provide meaningful run_name while training a model using MLflow Recipes

> #### Why is this use case valuable to support for your project(s) or organization?
Adds ability to easily identify the run. Also adds ability to create unique run_names for each profile. 

> #### Why is it currently difficult to achieve this use case?
There is no easy way to configure this in the YAML file right now


### Details

We could add config in the profile similar to experiment where we can configure the run_name per profile to easily identify the run.

### What component(s) does this bug affect?

- [ ] `area/artifacts`: Artifact stores and artifact logging
- [ ] `area/build`: Build and test infrastructure for MLflow
- [ ] `area/docs`: MLflow documentation pages
- [ ] `area/examples`: Example code
- [ ] `area/model-registry`: Model Registry service, APIs, and the fluent client calls for Model Registry
- [ ] `area/models`: MLmodel format, model serialization/deserialization, flavors
- [X] `area/recipes`: Recipes, Recipe APIs, Recipe configs, Recipe Templates
- [ ] `area/projects`: MLproject format, project running backends
- [ ] `area/scoring`: MLflow Model server, model deployment tools, Spark UDFs
- [ ] `area/server-infra`: MLflow Tracking server backend
- [ ] `area/tracking`: Tracking Service, tracking client APIs, autologging

### What interface(s) does this bug affect?

- [ ] `area/uiux`: Front-end, user experience, plotting, JavaScript, JavaScript dev server
- [ ] `area/docker`: Docker use across MLflow's components, such as MLflow Projects and MLflow Models
- [ ] `area/sqlalchemy`: Use of SQLAlchemy in the Tracking Service or Model Registry
- [ ] `area/windows`: Windows support

### What language(s) does this bug affect?

- [ ] `language/r`: R APIs and clients
- [ ] `language/java`: Java APIs and clients
- [ ] `language/new`: Proposals for new client languages

### What integration(s) does this bug affect?

- [ ] `integrations/azure`: Azure and Azure ML integrations
- [ ] `integrations/sagemaker`: SageMaker integrations
- [ ] `integrations/databricks`: Databricks integrations


Discussion:
<!-- assign-maintainer -->
@BenWilson2 @dbczumar @harupy @WeichenXu123 Please assign a maintainer and start triaging this issue.
Hi @sunishsheth2009 I can look into this issue. Can you assign it to me? I will open a PR in a few days.
@kamalesh0406 it's all yours!

Resolving patch:
diff --git a/mlflow/recipes/steps/train.py b/mlflow/recipes/steps/train.py
--- a/mlflow/recipes/steps/train.py
+++ b/mlflow/recipes/steps/train.py
@@ -349,9 +349,10 @@ def my_warn(*args, **kwargs):
                 ),
             }
 
+            run_name = self.tracking_config.run_name
             best_estimator_params = None
             mlflow.autolog(log_models=False, silent=True)
-            with mlflow.start_run(tags=tags) as run:
+            with mlflow.start_run(run_name=run_name, tags=tags) as run:
                 estimator = self._resolve_estimator(
                     X_train, y_train, validation_df, run, output_directory
                 )
@@ -1070,15 +1071,18 @@ def _tune_and_get_best_estimator_params(
 
         # wrap training in objective fn
         def objective(X_train, y_train, validation_df, hyperparameter_args, on_worker=False):
+            run_name = self.tracking_config.run_name
             if on_worker:
                 client = MlflowClient()
                 parent_tags = client.get_run(parent_run_id).data.tags
                 child_run = client.create_run(
-                    _get_experiment_id(), tags={**parent_tags, "mlflow.parentRunId": parent_run_id}
+                    _get_experiment_id(),
+                    tags={**parent_tags, "mlflow.parentRunId": parent_run_id},
+                    run_name=run_name,
                 )
                 run_args = {"run_id": child_run.info.run_id}
             else:
-                run_args = {"nested": True}
+                run_args = {"run_name": run_name, "nested": True}
             with mlflow.start_run(**run_args) as tuning_run:
                 estimator_args = dict(estimator_hardcoded_params, **hyperparameter_args)
                 estimator = estimator_fn(estimator_args)
diff --git a/mlflow/recipes/utils/tracking.py b/mlflow/recipes/utils/tracking.py
--- a/mlflow/recipes/utils/tracking.py
+++ b/mlflow/recipes/utils/tracking.py
@@ -39,6 +39,7 @@ class TrackingConfig:
     _KEY_TRACKING_URI = "mlflow_tracking_uri"
     _KEY_EXPERIMENT_NAME = "mlflow_experiment_name"
     _KEY_EXPERIMENT_ID = "mlflow_experiment_id"
+    _KEY_RUN_NAME = "mlflow_run_name"
     _KEY_ARTIFACT_LOCATION = "mlflow_experiment_artifact_location"
 
     def __init__(
@@ -46,6 +47,7 @@ def __init__(
         tracking_uri: str,
         experiment_name: str = None,
         experiment_id: str = None,
+        run_name: str = None,
         artifact_location: str = None,
     ):
         """
@@ -58,6 +60,8 @@ def __init__(
                               ``experiment_id`` must be specified. If both are specified, they
                               must be consistent with Tracking server state. Note that this
                               Experiment may not exist prior to recipe execution.
+        :param run_name: The MLFlow Run Name. If the run name is not specified, then a random
+                                name is set for the run.
         :param artifact_location: The artifact location to use for the Experiment, if the Experiment
                                   does not already exist. If the Experiment already exists, this
                                   location is ignored.
@@ -76,6 +80,7 @@ def __init__(
         self.tracking_uri = tracking_uri
         self.experiment_name = experiment_name
         self.experiment_id = experiment_id
+        self.run_name = run_name
         self.artifact_location = artifact_location
 
     def to_dict(self) -> Dict[str, str]:
@@ -97,6 +102,9 @@ def to_dict(self) -> Dict[str, str]:
         if self.artifact_location:
             config_dict[TrackingConfig._KEY_ARTIFACT_LOCATION] = self.artifact_location
 
+        if self.run_name:
+            config_dict[TrackingConfig._KEY_RUN_NAME] = self.run_name
+
         return config_dict
 
     @classmethod
@@ -111,6 +119,7 @@ def from_dict(cls, config_dict: Dict[str, str]) -> TrackingConfigType:
             tracking_uri=config_dict.get(TrackingConfig._KEY_TRACKING_URI),
             experiment_name=config_dict.get(TrackingConfig._KEY_EXPERIMENT_NAME),
             experiment_id=config_dict.get(TrackingConfig._KEY_EXPERIMENT_ID),
+            run_name=config_dict.get(TrackingConfig._KEY_RUN_NAME),
             artifact_location=config_dict.get(TrackingConfig._KEY_ARTIFACT_LOCATION),
         )
 
@@ -143,6 +152,7 @@ def get_recipe_tracking_config(
     tracking_config = recipe_config.get("experiment", {})
 
     config_obj_kwargs = {
+        "run_name": tracking_config.get("run_name", None),
         "tracking_uri": tracking_config.get("tracking_uri", default_tracking_uri),
         "artifact_location": tracking_config.get("artifact_location", default_artifact_location),
     }


Test patch:
diff --git a/tests/recipes/test_tracking_utils.py b/tests/recipes/test_tracking_utils.py
--- a/tests/recipes/test_tracking_utils.py
+++ b/tests/recipes/test_tracking_utils.py
@@ -20,18 +20,25 @@
 
 @pytest.mark.usefixtures("enter_test_recipe_directory")
 @pytest.mark.parametrize(
-    ("tracking_uri", "artifact_location", "experiment_name", "experiment_id"),
+    ("tracking_uri", "artifact_location", "experiment_name", "experiment_id", "run_name"),
     [
-        ("mysql://myhost:8000/test_uri", "test/artifact/location", "myexpname", "myexpid"),
-        ("mysql://myhost:8000/test_uri", "test/artifact/location", None, "myexpid"),
-        ("mysql://myhost:8000/test_uri", "test/artifact/location", "myexpname", None),
-        (None, "test/artifact/location", "myexpname", "myexpid"),
-        ("mysql://myhost:8000/test_uri", None, "myexpname", "myexpid"),
-        (None, None, None, "myexpid"),
+        (
+            "mysql://myhost:8000/test_uri",
+            "test/artifact/location",
+            "myexpname",
+            "myexpid",
+            "testrun",
+        ),
+        ("mysql://myhost:8000/test_uri", "test/artifact/location", None, "myexpid", "testrun"),
+        ("mysql://myhost:8000/test_uri", "test/artifact/location", "myexpname", None, "testrun"),
+        (None, "test/artifact/location", "myexpname", "myexpid", "testrun"),
+        ("mysql://myhost:8000/test_uri", None, "myexpname", "myexpid", "testrun"),
+        ("mysql://myhost:8000/test_uri", "test/artifact/location", "myexpname", "myexpid", None),
+        (None, None, None, "myexpid", None),
     ],
 )
 def test_get_recipe_tracking_config_returns_expected_config(
-    tracking_uri, artifact_location, experiment_name, experiment_id
+    tracking_uri, artifact_location, experiment_name, experiment_id, run_name
 ):
     default_tracking_uri = path_to_local_sqlite_uri(
         path=str((pathlib.Path.cwd() / "metadata" / "mlflow" / "mlruns.db").resolve())
@@ -55,6 +62,8 @@ def test_get_recipe_tracking_config_returns_expected_config(
         profile_contents["experiment"]["name"] = experiment_name
     if experiment_id is not None:
         profile_contents["experiment"]["id"] = experiment_id
+    if run_name is not None:
+        profile_contents["experiment"]["run_name"] = run_name
 
     profile_path = pathlib.Path.cwd() / "profiles" / "testprofile.yaml"
     with open(profile_path, "w") as f:
@@ -68,6 +77,7 @@ def test_get_recipe_tracking_config_returns_expected_config(
     assert recipe_tracking_config.artifact_location == (
         artifact_location or default_artifact_location
     )
+    assert recipe_tracking_config.run_name == (run_name or None)
     if experiment_name is not None:
         assert recipe_tracking_config.experiment_name == experiment_name
     elif experiment_id is not None:
@@ -78,18 +88,25 @@ def test_get_recipe_tracking_config_returns_expected_config(
 
 @pytest.mark.usefixtures("enter_test_recipe_directory")
 @pytest.mark.parametrize(
-    ("tracking_uri", "artifact_location", "experiment_name", "experiment_id"),
+    ("tracking_uri", "artifact_location", "experiment_name", "experiment_id", "run_name"),
     [
-        ("mysql://myhost:8000/test_uri", "test/artifact/location", "myexpname", "myexpid"),
-        ("mysql://myhost:8000/test_uri", "test/artifact/location", None, "myexpid"),
-        ("mysql://myhost:8000/test_uri", "test/artifact/location", "myexpname", None),
-        (None, "test/artifact/location", "myexpname", "myexpid"),
-        ("mysql://myhost:8000/test_uri", None, "myexpname", "myexpid"),
-        (None, None, None, "myexpid"),
+        (
+            "mysql://myhost:8000/test_uri",
+            "test/artifact/location",
+            "myexpname",
+            "myexpid",
+            "testrun",
+        ),
+        ("mysql://myhost:8000/test_uri", "test/artifact/location", None, "myexpid", "testrun"),
+        ("mysql://myhost:8000/test_uri", "test/artifact/location", "myexpname", None, "testrun"),
+        (None, "test/artifact/location", "myexpname", "myexpid", "testrun"),
+        ("mysql://myhost:8000/test_uri", None, "myexpname", "myexpid", "testrun"),
+        ("mysql://myhost:8000/test_uri", "test/artifact/location", "myexpname", "myexpid", None),
+        (None, None, None, "myexpid", None),
     ],
 )
 def test_get_recipe_tracking_config_returns_expected_config_on_databricks(
-    tracking_uri, artifact_location, experiment_name, experiment_id
+    tracking_uri, artifact_location, experiment_name, experiment_id, run_name
 ):
     with mock.patch("mlflow.recipes.utils.tracking.is_in_databricks_runtime", return_value=True):
         default_tracking_uri = "databricks"
@@ -109,6 +126,8 @@ def test_get_recipe_tracking_config_returns_expected_config_on_databricks(
             profile_contents["experiment"]["name"] = experiment_name
         if experiment_id is not None:
             profile_contents["experiment"]["id"] = experiment_id
+        if run_name is not None:
+            profile_contents["experiment"]["run_name"] = run_name
 
         profile_path = pathlib.Path.cwd() / "profiles" / "testprofile.yaml"
         with open(profile_path, "w") as f:
@@ -120,6 +139,7 @@ def test_get_recipe_tracking_config_returns_expected_config_on_databricks(
         )
         assert recipe_tracking_config.tracking_uri == (tracking_uri or default_tracking_uri)
         assert recipe_tracking_config.artifact_location == artifact_location
+        assert recipe_tracking_config.run_name == (run_name or None)
         if experiment_name is not None:
             assert recipe_tracking_config.experiment_name == experiment_name
         elif experiment_id is not None: