Repository: ray-project/ray

Issue:
[tune] support nested results for CSVLogger
`Trainable._train` may return a nested dictionary, e.g., 

```
{
  "train": {"loss": 1.0, "acc": 0.6, "f1": 0.5},
  "test": {"acc": 0.6, "f1": 0.5}
}
```

Currently, `CSVLogger` regards nested dictionaries as strings and directly writes them into file. It would be convenient for follow-up analysis to parse the nested dictionaries like this function [to_tf_values](https://github.com/ray-project/ray/blob/a62c5f40f67785439a143c6151f8dc8599351e94/python/ray/tune/logger.py#L109-L123).

Then, the header will be something like `train/loss,train/acc,train/f1,test/acc,test/f1`.


Discussion:
Oh that's a good idea! Would you be interested in this patch? There's a `flatten_dict` function in `tune/utils.py`, and putting that in the CSVLogger would only be a 5 line change. If not, I could take it later.

Resolving patch:
diff --git a/python/ray/tune/logger.py b/python/ray/tune/logger.py
--- a/python/ray/tune/logger.py
+++ b/python/ray/tune/logger.py
@@ -13,6 +13,7 @@
 import numpy as np
 
 import ray.cloudpickle as cloudpickle
+from ray.tune.util import flatten_dict
 from ray.tune.syncer import get_log_syncer
 from ray.tune.result import (NODE_IP, TRAINING_ITERATION, TIME_TOTAL_S,
                              TIMESTEPS_TOTAL, EXPR_PARAM_FILE,
@@ -107,19 +108,15 @@ def update_config(self, config):
 
 
 def to_tf_values(result, path):
-    values = []
-    for attr, value in result.items():
-        if value is not None:
-            if use_tf150_api:
-                type_list = [int, float, np.float32, np.float64, np.int32]
-            else:
-                type_list = [int, float]
-            if type(value) in type_list:
-                values.append(
-                    tf.Summary.Value(
-                        tag="/".join(path + [attr]), simple_value=value))
-            elif type(value) is dict:
-                values.extend(to_tf_values(value, path + [attr]))
+    if use_tf150_api:
+        type_list = [int, float, np.float32, np.float64, np.int32]
+    else:
+        type_list = [int, float]
+    flat_result = flatten_dict(result, delimiter="/")
+    values = [
+        tf.Summary.Value(tag="/".join(path + [attr]), simple_value=value)
+        for attr, value in flat_result.items() if type(value) in type_list
+    ]
     return values
 
 
@@ -175,6 +172,10 @@ def _init(self):
         self._csv_out = None
 
     def on_result(self, result):
+        tmp = result.copy()
+        if "config" in tmp:
+            del tmp["config"]
+        result = flatten_dict(tmp, delimiter="/")
         if self._csv_out is None:
             self._csv_out = csv.DictWriter(self._file, result.keys())
             if not self._continuing:
@@ -182,6 +183,7 @@ def on_result(self, result):
         self._csv_out.writerow(
             {k: v
              for k, v in result.items() if k in self._csv_out.fieldnames})
+        self._file.flush()
 
     def flush(self):
         self._file.flush()
diff --git a/python/ray/tune/util.py b/python/ray/tune/util.py
--- a/python/ray/tune/util.py
+++ b/python/ray/tune/util.py
@@ -180,14 +180,15 @@ def deep_update(original, new_dict, new_keys_allowed, whitelist):
     return original
 
 
-def flatten_dict(dt):
+def flatten_dict(dt, delimiter=":"):
+    dt = copy.deepcopy(dt)
     while any(isinstance(v, dict) for v in dt.values()):
         remove = []
         add = {}
         for key, value in dt.items():
             if isinstance(value, dict):
                 for subkey, v in value.items():
-                    add[":".join([key, subkey])] = v
+                    add[delimiter.join([key, subkey])] = v
                 remove.append(key)
         dt.update(add)
         for k in remove:


Test patch: