Skip to content
176 changes: 176 additions & 0 deletions mlflow/server/js/src/experiment-tracking/components/CompareRunBox.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import React, { useState } from 'react';
Comment thread
ahlag marked this conversation as resolved.
import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
import { Row, Col, Select } from 'antd';
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use databricks/design-system here instead of antd similar to this:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

databricks/design-system doesn't currently have support for OptGroup, so you'll have to do something like this

import { Select } from '@databricks/design-system';
import { Select as AntSelect } from 'antd';

const { Option } = Select
const { OptGroup } = AntSelect

Copy link
Copy Markdown
Member

@harupy harupy Jul 27, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use antd's Option and OptGroup for now because the scatter and contour plots currently use them, and later migrate to design-system's Option and antd's OptGroup in the next sync?

Copy link
Copy Markdown
Member

@harupy harupy Jul 27, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed with @hubertzub-db on this yesterday. We recently migrated the contour and scatter plot to @databricks/design-system (with antd.Select.OptGroup) in Databricks. When we export this migration in the next UI sync, we can also apply the same change to the box plot.

import { Typography } from '@databricks/design-system';
import { RunInfo } from '../sdk/MlflowMessages';
import { LazyPlot } from './LazyPlot';

const { Option, OptGroup } = Select;

export const CompareRunBox = ({ runUuids, runInfos, metricLists, paramLists }) => {
const [xAxis, setXAxis] = useState({ key: undefined, isParam: undefined });
const [yAxis, setYAxis] = useState({ key: undefined, isParam: undefined });

const paramKeys = Array.from(new Set(paramLists.flat().map(({ key }) => key))).sort();
const metricKeys = Array.from(new Set(metricLists.flat().map(({ key }) => key))).sort();

const paramOptionPrefix = 'param-';
const metricOptionPrefix = 'metric-';

const handleXAxisChange = (_, { value, key }) => {
const isParam = value.startsWith(paramOptionPrefix);
setXAxis({ key, isParam });
};

const handleYAxisChange = (_, { value, key }) => {
const isParam = value.startsWith(paramOptionPrefix);
setYAxis({ key, isParam });
};

const renderSelector = (onChange, selectedValue) => (
<Select
css={{ width: '100%', marginBottom: '16px' }}
placeholder='Select'
onChange={onChange}
value={selectedValue}
>
<OptGroup label='Parameters' key='parameters'>
{paramKeys.map((key) => (
<Option key={key} value={paramOptionPrefix + key}>
<div data-test-id='axis-option'>{key}</div>
</Option>
))}
</OptGroup>
<OptGroup label='Metrics'>
{metricKeys.map((key) => (
<Option key={key} value={metricOptionPrefix + key}>
<div data-test-id='axis-option'>{key}</div>
</Option>
))}
</OptGroup>
</Select>
);

const getBoxPlotData = () => {
const data = {};
runInfos.forEach((_, index) => {
const params = paramLists[index];
const metrics = metricLists[index];
const x = (xAxis.isParam ? params : metrics).find(({ key }) => key === xAxis.key);
const y = (yAxis.isParam ? params : metrics).find(({ key }) => key === yAxis.key);
if (x === undefined || y === undefined) {
return;
}

if (x.value in data) {
data[x.value].push(y.value);
} else {
data[x.value] = [y.value];
}
});

return Object.entries(data).map(([key, values]) => ({
y: values,
type: 'box',
name: key,
jitter: 0.3,
pointpos: -1.5,
boxpoints: 'all',
}));
};

const renderPlot = () => {
if (!(xAxis.key && yAxis.key)) {
return (
<div
css={{
display: 'flex',
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Typography.Text size='xl'>
<FormattedMessage
defaultMessage='Select parameters/metrics to plot.'
description='Text to show when x or y axis is not selected on box plot'
/>
</Typography.Text>
</div>
);
}

return (
<LazyPlot
css={{
width: '100%',
height: '100%',
minHeight: '35vw',
}}
data={getBoxPlotData()}
layout={{
margin: {
t: 30,
},
hovermode: 'closest',
xaxis: {
title: xAxis.key,
},
yaxis: {
title: yAxis.key,
},
}}
config={{
responsive: true,
displaylogo: false,
scrollZoom: true,
modeBarButtonsToRemove: [
'sendDataToCloud',
'select2d',
'lasso2d',
'resetScale2d',
'hoverClosestCartesian',
'hoverCompareCartesian',
],
}}
useResizeHandler
/>
);
};

return (
<Row>
<Col span={6}>
<div>
<label htmlFor='x-axis-selector'>
<FormattedMessage
defaultMessage='X-axis:'
description='Label text for X-axis in box plot comparison in MLflow'
/>
</label>
</div>
{renderSelector(handleXAxisChange, xAxis.value)}

<div>
<label htmlFor='y-axis-selector'>
<FormattedMessage
defaultMessage='Y-axis:'
description='Label text for Y-axis in box plot comparison in MLflow'
/>
</label>
</div>
{renderSelector(handleYAxisChange, yAxis.value)}
</Col>
<Col span={18}>{renderPlot()}</Col>
</Row>
);
};

CompareRunBox.propTypes = {
runUuids: PropTypes.arrayOf(PropTypes.string).isRequired,
runInfos: PropTypes.arrayOf(PropTypes.instanceOf(RunInfo)).isRequired,
metricLists: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.object)).isRequired,
paramLists: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.object)).isRequired,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import React from 'react';
import { Select } from 'antd';
import { CompareRunBox } from './CompareRunBox';
import { RunInfo } from '../sdk/MlflowMessages';
import { mountWithIntl } from '../../common/utils/TestUtils';
import { LazyPlot } from './LazyPlot';

describe('CompareRunBox', () => {
let wrapper;

const runUuids = ['1', '2', '3'];
const commonProps = {
runUuids,
runInfos: runUuids.map((run_uuid) =>
RunInfo.fromJs({
run_uuid,
experiment_id: '0',
}),
),
runDisplayNames: runUuids,
};

test('should render with minimal props without exploding', () => {
const props = {
...commonProps,
paramLists: [
[{ key: 'param', value: 1 }],
[{ key: 'param', value: 2 }],
[{ key: 'param', value: 3 }],
],
metricLists: [
[{ key: 'metric', value: 4 }],
[{ key: 'metric', value: 5 }],
[{ key: 'metric', value: 6 }],
],
};

wrapper = mountWithIntl(<CompareRunBox {...props} />);
expect(wrapper.find(LazyPlot).isEmpty()).toBe(true);
expect(wrapper.text()).toContain('Select parameters/metrics to plot.');

const selectors = wrapper.find(Select);
expect(selectors.length).toBe(2);
// Set x-axis to 'param'
const xAxisSelector = selectors.at(0);
xAxisSelector.find('input[type="search"]').simulate('mouseDown');
// `wrapper.find` can't find the selector options because they appear in the top level of the
// document.
document.querySelectorAll('[data-test-id="axis-option"]')[0].click();
expect(xAxisSelector.text()).toContain('param');
// Set y-axis to 'metric'
const yAxisSelector = selectors.at(1);
yAxisSelector.find('input[type="search"]').simulate('mouseDown');
document.querySelectorAll('[data-test-id="axis-option"]')[3].click();
expect(yAxisSelector.text()).toContain('metric');
wrapper.update();
expect(wrapper.find(LazyPlot).exists()).toBe(true);
expect(wrapper.text()).not.toContain('Select parameters/metrics to plot.');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { injectIntl, FormattedMessage } from 'react-intl';
import './CompareRunView.css';
import { Experiment, RunInfo } from '../sdk/MlflowMessages';
import { CompareRunScatter } from './CompareRunScatter';
import { CompareRunBox } from './CompareRunBox';
import CompareRunContour from './CompareRunContour';
import Routes from '../routes';
import { Link } from 'react-router-dom';
Expand Down Expand Up @@ -366,7 +367,7 @@ export class CompareRunView extends Component {

render() {
const { experimentIds } = this.props;
const { runInfos, runNames } = this.props;
const { runInfos, runNames, paramLists, metricLists, runUuids } = this.props;

const colWidth = this.getTableColumnWidth();
const colWidthStyle = this.genWidthStyle(colWidth);
Expand All @@ -392,7 +393,7 @@ export class CompareRunView extends Component {
description='Tab pane title for parallel coordinate plots on the compare runs page'
/>
}
key='1'
key='parallel-coordinates-plot'
>
<ParallelCoordinatesPlotPanel runUuids={this.props.runUuids} />
</TabPane>
Expand All @@ -403,21 +404,37 @@ export class CompareRunView extends Component {
description='Tab pane title for scatterplots on the compare runs page'
/>
}
key='2'
key='scatter-plot'
>
<CompareRunScatter
runUuids={this.props.runUuids}
runDisplayNames={this.props.runDisplayNames}
/>
</TabPane>
<TabPane
tab={
<FormattedMessage
defaultMessage='Box Plot'
description='Tab pane title for box plot on the compare runs page'
/>
}
key='box-plot'
>
<CompareRunBox
runUuids={runUuids}
runInfos={runInfos}
paramLists={paramLists}
metricLists={metricLists}
/>
</TabPane>
<TabPane
tab={
<FormattedMessage
defaultMessage='Contour Plot'
description='Tab pane title for contour plots on the compare runs page'
/>
}
key='3'
key='contour-plot'
>
<CompareRunContour
runUuids={this.props.runUuids}
Expand Down
16 changes: 16 additions & 0 deletions mlflow/server/js/src/i18n/default/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,10 @@
"defaultMessage": "Time (Wall)",
"description": "Radio button option to choose the time wall control option for the X-axis for metric graph on the experiment runs"
},
"8xji5J": {
"defaultMessage": "X-axis:",
"description": "Label text for X-axis in box plot comparison in MLflow"
},
"93iLUV": {
"defaultMessage": "{timeSince, plural, =1 {1 second} other {# seconds}} ago",
"description": "Text for time in seconds since given date for MLflow views"
Expand Down Expand Up @@ -459,6 +463,10 @@
"defaultMessage": "Are you sure you want to delete {modelName}? This cannot be undone.",
"description": "Confirmation message for delete model modal on model view page"
},
"Hk0RSH": {
"defaultMessage": "Y-axis:",
"description": "Label text for Y-axis in box plot comparison in MLflow"
},
"Hq00g9": {
"defaultMessage": "On",
"description": "Checked toggle text for reverse color toggle in contour plot\n comparison in MLflow"
Expand Down Expand Up @@ -715,6 +723,10 @@
"defaultMessage": "Version {versionNum}",
"description": "Text for current version under the header on the model version view page"
},
"XaD7FM": {
"defaultMessage": "Select parameters/metrics to plot.",
"description": "Text to show when x or y axis is not selected on box plot"
},
"Xqs6q2": {
"defaultMessage": "Without Model Versions",
"description": "Linked model dropdown option to show experiment runs without model versions only"
Expand Down Expand Up @@ -947,6 +959,10 @@
"defaultMessage": "Min",
"description": "Column title for the column displaying the minimum metric values for a metric"
},
"iXb99e": {
"defaultMessage": "Box Plot",
"description": "Tab pane title for box plot on the compare runs page"
},
"iZm6YQ": {
"defaultMessage": "<link>Learn more</link>",
"description": "Learn more link on the form for creating model in the model registry"
Expand Down
Loading