Repository: iterative/dvc

Issue:
dvc exp show: table display problem
# Bug Report

## Description

I have a (toy) project with two pipeline folder (`pip1` and pip2`), each one has its own `params.yaml` and `metrics.json` both containing the same fields with différent values.

Parameters:
```
$ dvc params diff --all
Path              Param       Old    New                              
pip1/params.yaml  some_param  42     42
pip2/params.yaml  some_param  33     33
```
Metrics:
```
$ dvc metrics diff --all
Path               Metric    Old    New    Change                     
pip1/metrics.json  accuracy  —      0.42   —
pip2/metrics.json  accuracy  —      0.33   —
```
Experiments table:
```
$ dvc exp show --no-pager
```
![image](https://user-images.githubusercontent.com/151000/104496463-53600d00-55d9-11eb-8dba-67ef8e06b75b.png)

(I included a screenshot to avoir copy-paste formatting errors).

Project layout:
```
$ tree
.
├── README.md
├── data
│   ├── data.txt
│   ├── dvc.yaml
│   ├── data_count.txt
│   ├── dvc.lock
│   ├── dvc.yaml~
│   └── data_copy.txt
├── pip2
│   ├── params.yaml
│   ├── count_out.txt
│   ├── dvc.yaml
│   ├── dvc.lock
│   ├── dvc.yaml~
│   └── metrics.json
└── pip1
    ├── params.yaml
    ├── dvc.lock
    ├── dvc.yaml~
    ├── metrics.json
    ├── data_out.txt
    └── dvc.yaml
```


### Reproduce
1. create two pipeline folders and `dvc.yaml` files
2. create one `params.yaml` file for each folder containing the same variable (with different values)
3. create one `metrics.json` file for each folder containing the same fields (with different values)
4. Make sure `dvc.yaml` files each refer to their proper `params.yaml` and `metrics.json` files
5. run `dvc exp show`


### Expected

Proper display of the informations in the table (not sure how to display different pipelines)

### Environment information

```console
$ dvc version
DVC version: 2.0.0a0+e5f91f 
---------------------------------
Platform: Python 3.9.1 on Linux-5.10.4-arch2-1-x86_64-with-glibc2.32
Supports: http, https, s3, ssh
Cache types: hardlink, symlink
Cache directory: ext4 on /dev/sdb1
Caches: local
Remotes: None
Workspace directory: ext4 on /dev/sdb1
Repo: dvc, git
```


discord context https://discord.com/channels/485586884165107732/563406153334128681/798989147574501406
dvc: metrics.show() and params.show() order
It looks like these go in somewhat random order. Is it possible to make them reflect the order in `dvc.yaml` file? Since Python 3.6 dicts preserve order so we can still use that as an output type. 


Discussion:

@Suor Could you elaborate why that even matters?
To use as a default order of columns in the viewer. Sure we can simply sort alphabetically, but the order in a file is probably user defined already and then we should use it.
That seems really viewer-specific. Not sure if it is worth adopting as dvc core behavior. Have you implemented it in the viewer already?
Ok, had a private discussion about it. So this matters for dvc CLI as well, so it is our problem too, though not as urgent. We don't have capacity to deal with it right now ourselves, so if it can be implemented in viewer for now or you could contribute the change back (at least for API) - that would be great.
Can't be implemented in the viewer (without monkey patching). So no not implemented yet in any way.

Resolving patch:
diff --git a/dvc/command/experiments.py b/dvc/command/experiments.py
--- a/dvc/command/experiments.py
+++ b/dvc/command/experiments.py
@@ -1,7 +1,7 @@
 import argparse
 import io
 import logging
-from collections import OrderedDict
+from collections import Counter, OrderedDict, defaultdict
 from collections.abc import Mapping
 from datetime import date, datetime
 from itertools import groupby
@@ -18,8 +18,44 @@
 logger = logging.getLogger(__name__)
 
 
+def _filter_name(names, label, filter_strs):
+    ret = defaultdict(dict)
+    path_filters = defaultdict(list)
+
+    for filter_s in filter_strs:
+        path, _, name = filter_s.rpartition(":")
+        path_filters[path].append(tuple(name.split(".")))
+
+    for path, filters in path_filters.items():
+        if path:
+            match_paths = [path]
+        else:
+            match_paths = names.keys()
+        for length, groups in groupby(filters, len):
+            for group in groups:
+                for match_path in match_paths:
+                    possible_names = [
+                        tuple(name.split(".")) for name in names[match_path]
+                    ]
+                    matches = [
+                        name
+                        for name in possible_names
+                        if name[:length] == group
+                    ]
+                    if not matches:
+                        name = ".".join(group)
+                        raise InvalidArgumentError(
+                            f"'{name}' does not match any known {label}"
+                        )
+                    ret[match_path].update(
+                        {".".join(match): None for match in matches}
+                    )
+
+    return ret
+
+
 def _filter_names(
-    names: Iterable,
+    names: Dict[str, Dict[str, None]],
     label: str,
     include: Optional[Iterable],
     exclude: Optional[Iterable],
@@ -33,48 +69,32 @@ def _filter_names(
                 f" --exclude-{label}"
             )
 
-    names = [tuple(name.split(".")) for name in names]
-
-    def _filter(filters, update_func):
-        filters = [tuple(name.split(".")) for name in filters]
-        for length, groups in groupby(filters, len):
-            for group in groups:
-                matches = [name for name in names if name[:length] == group]
-                if not matches:
-                    name = ".".join(group)
-                    raise InvalidArgumentError(
-                        f"'{name}' does not match any known {label}"
-                    )
-                update_func({match: None for match in matches})
-
     if include:
-        ret: OrderedDict = OrderedDict()
-        _filter(include, ret.update)
+        ret = _filter_name(names, label, include)
     else:
-        ret = OrderedDict({name: None for name in names})
+        ret = names
 
     if exclude:
-        to_remove: Dict[str, Optional[str]] = {}
-        _filter(exclude, to_remove.update)
-        for key in to_remove:
-            if key in ret:
-                del ret[key]
+        to_remove = _filter_name(names, label, exclude)
+        for path in to_remove:
+            if path in ret:
+                for key in to_remove[path]:
+                    if key in ret[path]:
+                        del ret[path][key]
 
-    return [".".join(name) for name in ret]
+    return ret
 
 
 def _update_names(names, items):
     for name, item in items:
         if isinstance(item, dict):
             item = flatten(item)
-            names.update(item.keys())
-        else:
-            names[name] = None
+            names[name].update({key: None for key in item})
 
 
 def _collect_names(all_experiments, **kwargs):
-    metric_names = set()
-    param_names = set()
+    metric_names = defaultdict(dict)
+    param_names = defaultdict(dict)
 
     for _, experiments in all_experiments.items():
         for exp in experiments.values():
@@ -82,13 +102,13 @@ def _collect_names(all_experiments, **kwargs):
             _update_names(param_names, exp.get("params", {}).items())
 
     metric_names = _filter_names(
-        sorted(metric_names),
+        metric_names,
         "metrics",
         kwargs.get("include_metrics"),
         kwargs.get("exclude_metrics"),
     )
     param_names = _filter_names(
-        sorted(param_names),
+        (param_names),
         "params",
         kwargs.get("include_params"),
         kwargs.get("exclude_params"),
@@ -231,7 +251,7 @@ def _extend_row(row, names, items, precision):
             item = flatten(item)
         else:
             item = {fname: item}
-        for name in names:
+        for name in names[fname]:
             if name in item:
                 value = item[name]
                 if value is None:
@@ -246,24 +266,24 @@ def _extend_row(row, names, items, precision):
                 row.append("-")
 
 
-def _parse_list(param_list):
+def _parse_filter_list(param_list):
     ret = []
     for param_str in param_list:
-        # we don't care about filename prefixes for show, silently
-        # ignore it if provided to keep usage consistent with other
-        # metric/param list command options
-        _, _, param_str = param_str.rpartition(":")
-        ret.extend(param_str.split(","))
+        path, _, param_str = param_str.rpartition(":")
+        if path:
+            ret.extend(f"{path}:{param}" for param in param_str.split(","))
+        else:
+            ret.extend(param_str.split(","))
     return ret
 
 
 def _show_experiments(all_experiments, console, **kwargs):
     from rich.table import Table
 
-    include_metrics = _parse_list(kwargs.pop("include_metrics", []))
-    exclude_metrics = _parse_list(kwargs.pop("exclude_metrics", []))
-    include_params = _parse_list(kwargs.pop("include_params", []))
-    exclude_params = _parse_list(kwargs.pop("exclude_params", []))
+    include_metrics = _parse_filter_list(kwargs.pop("include_metrics", []))
+    exclude_metrics = _parse_filter_list(kwargs.pop("exclude_metrics", []))
+    include_params = _parse_filter_list(kwargs.pop("include_params", []))
+    exclude_params = _parse_filter_list(kwargs.pop("exclude_params", []))
 
     metric_names, param_names = _collect_names(
         all_experiments,
@@ -277,10 +297,8 @@ def _show_experiments(all_experiments, console, **kwargs):
     table.add_column("Experiment", no_wrap=True)
     if not kwargs.get("no_timestamp", False):
         table.add_column("Created")
-    for name in metric_names:
-        table.add_column(name, justify="right", no_wrap=True)
-    for name in param_names:
-        table.add_column(name, justify="left")
+    _add_data_col(table, metric_names, justify="right", no_wrap=True)
+    _add_data_col(table, param_names, justify="left")
 
     for base_rev, experiments in all_experiments.items():
         for row, _, in _collect_rows(
@@ -291,6 +309,16 @@ def _show_experiments(all_experiments, console, **kwargs):
     console.print(table)
 
 
+def _add_data_col(table, names, **kwargs):
+    count = Counter(
+        name for path in names for name in names[path] for path in names
+    )
+    for path in names:
+        for name in names[path]:
+            col_name = name if count[name] == 1 else f"{path}:{name}"
+            table.add_column(col_name, **kwargs)
+
+
 def _format_json(item):
     if isinstance(item, (date, datetime)):
         return item.isoformat()


Test patch: