diff --git a/dvc/render/plotly.py b/dvc/render/plotly.py new file mode 100644 index 000000000..bb630023d --- /dev/null +++ b/dvc/render/plotly.py @@ -0,0 +1,80 @@ +import json +from collections import defaultdict +from typing import TYPE_CHECKING, Any, Dict, Optional + +from dvc.render.base import Renderer + +if TYPE_CHECKING: + from dvc.compare import TabularData + + +class ParallelCoordinatesRenderer(Renderer): + TYPE = "plotly" + + DIV = """ +
+ +
+ """ + + SCRIPTS = """ + + """ + + # pylint: disable=W0231 + def __init__( + self, tabular_data: "TabularData", color_by: Optional[str] = None + ): + self.tabular_data = tabular_data + self.color_by = color_by + self.filename = "experiments" + + def partial_html(self, **kwargs): + return self.as_json() + + def as_json(self, **kwargs) -> str: + tabular_dict = defaultdict(list) + for row in self.tabular_data.as_dict(): + for col_name, value in row.items(): + tabular_dict[col_name].append(str(value)) + + trace: Dict[str, Any] = {"type": "parcoords", "dimensions": []} + for label, values in tabular_dict.items(): + is_categorical = False + + try: + float_values = [float(x) for x in values] + except ValueError: + is_categorical = True + dummy_values = list(range(len(values))) + + if is_categorical: + trace["dimensions"].append( + { + "label": label, + "values": dummy_values, + "tickvals": dummy_values, + "ticktext": values, + } + ) + else: + trace["dimensions"].append( + {"label": label, "values": float_values} + ) + + if label == self.color_by: + trace["line"] = { + "color": dummy_values if is_categorical else float_values, + "showscale": True, + } + if is_categorical: + trace["line"]["colorbar"] = { + "tickmode": "array", + "tickvals": dummy_values, + "ticktext": values, + } + + return json.dumps({"data": [trace], "layout": {}}) diff --git a/tests/unit/render/test_parallel_coordinates.py b/tests/unit/render/test_parallel_coordinates.py new file mode 100644 index 000000000..8c2614c1c --- /dev/null +++ b/tests/unit/render/test_parallel_coordinates.py @@ -0,0 +1,123 @@ +import json + +from dvc.compare import TabularData +from dvc.render.html import write +from dvc.render.plotly import ParallelCoordinatesRenderer + +# pylint: disable=W1514 + + +def expected_format(result): + assert "data" in result + assert "layout" in result + assert isinstance(result["data"], list) + assert result["data"][0]["type"] == "parcoords" + assert isinstance(result["data"][0]["dimensions"], list) + return True + + +def test_scalar_columns(): + td = TabularData(["col-1", "col-2"]) + td.extend([["0.1", "1"], ["2", "0.2"]]) + renderer = ParallelCoordinatesRenderer(td) + + result = json.loads(renderer.as_json()) + + assert expected_format(result) + + assert result["data"][0]["dimensions"][0] == { + "label": "col-1", + "values": [0.1, 2.0], + } + assert result["data"][0]["dimensions"][1] == { + "label": "col-2", + "values": [1.0, 0.2], + } + + +def test_categorical_columns(): + td = TabularData(["col-1"]) + td.extend([["foo"], ["bar"]]) + renderer = ParallelCoordinatesRenderer(td) + + result = json.loads(renderer.as_json()) + + assert expected_format(result) + + assert result["data"][0]["dimensions"][0] == { + "label": "col-1", + "values": [0, 1], + "tickvals": [0, 1], + "ticktext": ["foo", "bar"], + } + + +def test_mixed_columns(): + td = TabularData(["categorical", "scalar"]) + td.extend([["foo", "0.1"], ["bar", "2"]]) + renderer = ParallelCoordinatesRenderer(td) + + result = json.loads(renderer.as_json()) + + assert expected_format(result) + + assert result["data"][0]["dimensions"][0] == { + "label": "categorical", + "values": [0, 1], + "tickvals": [0, 1], + "ticktext": ["foo", "bar"], + } + assert result["data"][0]["dimensions"][1] == { + "label": "scalar", + "values": [0.1, 2.0], + } + + +def test_color_by_scalar(): + td = TabularData(["categorical", "scalar"]) + td.extend([["foo", "0.1"], ["bar", "2"]]) + renderer = ParallelCoordinatesRenderer(td, color_by="scalar") + + result = json.loads(renderer.as_json()) + + assert expected_format(result) + assert result["data"][0]["line"] == { + "color": [0.1, 2.0], + "showscale": True, + } + + +def test_color_by_categorical(): + td = TabularData(["categorical", "scalar"]) + td.extend([["foo", "0.1"], ["bar", "2"]]) + renderer = ParallelCoordinatesRenderer(td, color_by="categorical") + + result = json.loads(renderer.as_json()) + + assert expected_format(result) + assert result["data"][0]["line"] == { + "color": [0, 1], + "showscale": True, + "colorbar": { + "tickmode": "array", + "tickvals": [0, 1], + "ticktext": ["foo", "bar"], + }, + } + + +def test_write_parallel_coordinates(tmp_dir): + td = TabularData(["categorical", "scalar"]) + td.extend([["foo", "0.1"], ["bar", "2"]]) + + renderer = ParallelCoordinatesRenderer(td) + html_path = write(tmp_dir, renderers=[renderer]) + + html_text = html_path.read_text() + + assert ParallelCoordinatesRenderer.SCRIPTS in html_text + + div = ParallelCoordinatesRenderer.DIV.format( + id="plot_experiments", partial=renderer.as_json() + ) + assert div in html_text