diff --git a/superset/assets/images/viz_thumbnails/paired_ttest.png b/superset/assets/images/viz_thumbnails/paired_ttest.png new file mode 100644 index 00000000000..4f8ad71b121 Binary files /dev/null and b/superset/assets/images/viz_thumbnails/paired_ttest.png differ diff --git a/superset/assets/javascripts/explore/stores/controls.jsx b/superset/assets/javascripts/explore/stores/controls.jsx index 894b5a4f6fd..66659043401 100644 --- a/superset/assets/javascripts/explore/stores/controls.jsx +++ b/superset/assets/javascripts/explore/stores/controls.jsx @@ -1370,5 +1370,26 @@ export const controls = { description: t('The color scheme for rendering chart'), schemes: ALL_COLOR_SCHEMES, }, + + significance_level: { + type: 'TextControl', + label: 'Significance Level', + default: 0.05, + description: 'Threshold alpha level for determining significance', + }, + + pvalue_precision: { + type: 'TextControl', + label: 'p-value precision', + default: 6, + description: 'Number of decimal places with which to display p-values', + }, + + liftvalue_precision: { + type: 'TextControl', + label: 'Lift % precision', + default: 4, + description: 'Number of decimal places with which to display lift values', + }, }; export default controls; diff --git a/superset/assets/javascripts/explore/stores/visTypes.js b/superset/assets/javascripts/explore/stores/visTypes.js index 8298afce0d1..011f44d0776 100644 --- a/superset/assets/javascripts/explore/stores/visTypes.js +++ b/superset/assets/javascripts/explore/stores/visTypes.js @@ -1101,6 +1101,24 @@ export const visTypes = { }, }, }, + + paired_ttest: { + label: 'Time Series - Paired t-test', + showOnExplore: true, + requiresTime: true, + controlPanelSections: [ + sections.NVD3TimeSeries[0], + { + label: 'Paired t-test', + expanded: false, + controlSetRows: [ + ['significance_level'], + ['pvalue_precision'], + ['liftvalue_precision'], + ], + }, + ], + }, }; export default visTypes; diff --git a/superset/assets/package.json b/superset/assets/package.json index 729bb022a66..3b1c53b07e8 100644 --- a/superset/assets/package.json +++ b/superset/assets/package.json @@ -53,6 +53,7 @@ "d3-tip": "^0.6.7", "datamaps": "^0.5.8", "datatables.net-bs": "^1.10.15", + "distributions": "^1.0.0", "immutable": "^3.8.1", "jed": "^1.1.1", "po2json": "^0.4.5", diff --git a/superset/assets/visualizations/main.js b/superset/assets/visualizations/main.js index a02f508c330..d5c3abb1a7e 100644 --- a/superset/assets/visualizations/main.js +++ b/superset/assets/visualizations/main.js @@ -33,5 +33,6 @@ const vizMap = { world_map: require('./world_map.js'), dual_line: require('./nvd3_vis.js'), event_flow: require('./EventFlow.jsx'), + paired_ttest: require('./paired_ttest.jsx'), }; export default vizMap; diff --git a/superset/assets/visualizations/paired_ttest.css b/superset/assets/visualizations/paired_ttest.css new file mode 100644 index 00000000000..0a2c1b8d28f --- /dev/null +++ b/superset/assets/visualizations/paired_ttest.css @@ -0,0 +1,67 @@ +.paired_ttest .scrollbar-container { + overflow: scroll; +} + +.paired-ttest-table .scrollbar-content { + padding-left: 5px; + padding-right: 5px; + margin-bottom: 0; +} + +.paired-ttest-table h1 { + margin-left: 5px; +} + +.reactable-data tr, +.reactable-header-sortable { + -webkit-transition: ease-in-out 0.1s; + transition: ease-in-out 0.1s; +} + +.reactable-data tr:hover { + background-color: #e0e0e0; +} + +.reactable-data tr .false { + color: #f44336; +} + +.reactable-data tr .true { + color: #4caf50; +} + +.reactable-data tr .control { + color: #2196f3; +} + +.reactable-data tr .invalid { + color: #ff9800; +} + +.reactable-data .control td { + background-color: #eeeeee; +} + +.reactable-header-sortable:hover, +.reactable-header-sortable:focus, +.reactable-header-sort-asc, +.reactable-header-sort-desc { + background-color: #e0e0e0; + position: relative; +} + +.reactable-header-sort-asc:after { + content: '\25bc'; + position: absolute; + right: 10px; +} + +.reactable-header-sort-desc:after { + content: '\25b2'; + position: absolute; + right: 10px; +} + +.paired-ttest-table table { + margin-bottom: 0; +} diff --git a/superset/assets/visualizations/paired_ttest.jsx b/superset/assets/visualizations/paired_ttest.jsx new file mode 100644 index 00000000000..9febc798b09 --- /dev/null +++ b/superset/assets/visualizations/paired_ttest.jsx @@ -0,0 +1,277 @@ +import d3 from 'd3'; +import dist from 'distributions'; + +import React from 'react'; +import { Table, Tr, Td, Thead, Th } from 'reactable'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; + +import './paired_ttest.css'; + +class TTestTable extends React.Component { + + constructor(props) { + super(props); + this.state = { + pValues: [], + liftValues: [], + control: 0, + }; + } + + componentWillMount() { + this.computeTTest(this.state.control); // initially populate table + } + + getLiftStatus(row) { + // Get a css class name for coloring + if (row === this.state.control) { + return 'control'; + } + const liftVal = this.state.liftValues[row]; + if (isNaN(liftVal) || !isFinite(liftVal)) { + return 'invalid'; // infinite or NaN values + } + return liftVal >= 0 ? 'true' : 'false'; // green on true, red on false + } + + getPValueStatus(row) { + if (row === this.state.control) { + return 'control'; + } + const pVal = this.state.pValues[row]; + if (isNaN(pVal) || !isFinite(pVal)) { + return 'invalid'; + } + return ''; // p-values won't normally be colored + } + + getSignificance(row) { + // Color significant as green, else red + if (row === this.state.control) { + return 'control'; + } + // p-values significant below set threshold + return this.state.pValues[row] <= this.props.alpha; + } + + computeLift(values, control) { + // Compute the lift value between two time series + let sumValues = 0; + let sumControl = 0; + for (let i = 0; i < values.length; i++) { + sumValues += values[i].y; + sumControl += control[i].y; + } + return (((sumValues - sumControl) / sumControl) * 100) + .toFixed(this.props.liftValPrec); + } + + computePValue(values, control) { + // Compute the p-value from Student's t-test + // between two time series + let diffSum = 0; + let diffSqSum = 0; + let finiteCount = 0; + for (let i = 0; i < values.length; i++) { + const diff = control[i].y - values[i].y; + if (global.isFinite(diff)) { + finiteCount++; + diffSum += diff; + diffSqSum += diff * diff; + } + } + const tvalue = -Math.abs(diffSum * + Math.sqrt((finiteCount - 1) / + (finiteCount * diffSqSum - diffSum * diffSum))); + try { + return (2 * new dist.Studentt(finiteCount - 1).cdf(tvalue)) + .toFixed(this.props.pValPrec); // two-sided test + } catch (err) { + return NaN; + } + } + + computeTTest(control) { + // Compute lift and p-values for each row + // against the selected control + const data = this.props.data; + const pValues = []; + const liftValues = []; + if (!data) { + return; + } + for (let i = 0; i < data.length; i++) { + if (i === control) { + pValues.push('control'); + liftValues.push('control'); + } else { + pValues.push(this.computePValue(data[i].values, data[control].values)); + liftValues.push(this.computeLift(data[i].values, data[control].values)); + } + } + this.setState({ pValues, liftValues, control }); + } + + render() { + const data = this.props.data; + const metric = this.props.metric; + const groups = this.props.groups; + // Render column header for each group + const columns = groups.map((group, i) => ( +