import React from "react"; import { select, pointer } from "d3-selection"; import { scaleLinear, scaleTime } from "d3-scale"; import { format } from "d3-format"; import { timeFormat, timeParse } from "d3-time-format"; import { axisBottom, axisLeft } from "d3-axis"; import { line } from "d3-shape"; import { hsl } from "d3-color"; import { sortBy, min, max, map, each, sum, filter, debounce, keys, range, rangeRight, cloneDeep, findKey } from "lodash"; import colors from "./color-set"; class AdditiveForceArrayVisualizer extends React.Component { constructor() { super(); window.lastAdditiveForceArrayVisualizer = this; this.topOffset = 28; this.leftOffset = 80; this.height = 350; this.effectFormat = format(".2"); this.redraw = debounce(() => this.draw(), 200); } componentDidMount() { // create our permanent elements this.mainGroup = this.svg.append("g"); this.onTopGroup = this.svg.append("g"); this.xaxisElement = this.onTopGroup .append("g") .attr("transform", "translate(0,35)") .attr("class", "force-bar-array-xaxis"); this.yaxisElement = this.onTopGroup .append("g") .attr("transform", "translate(0,35)") .attr("class", "force-bar-array-yaxis"); this.hoverGroup1 = this.svg.append("g"); this.hoverGroup2 = this.svg.append("g"); this.baseValueTitle = this.svg.append("text"); this.hoverLine = this.svg.append("line"); this.hoverxOutline = this.svg .append("text") .attr("text-anchor", "middle") .attr("font-weight", "bold") .attr("fill", "#fff") .attr("stroke", "#fff") .attr("stroke-width", "6") .attr("font-size", "12px"); this.hoverx = this.svg .append("text") .attr("text-anchor", "middle") .attr("font-weight", "bold") .attr("fill", "#000") .attr("font-size", "12px"); this.hoverxTitle = this.svg .append("text") .attr("text-anchor", "middle") .attr("opacity", 0.6) .attr("font-size", "12px"); this.hoveryOutline = this.svg .append("text") .attr("text-anchor", "end") .attr("font-weight", "bold") .attr("fill", "#fff") .attr("stroke", "#fff") .attr("stroke-width", "6") .attr("font-size", "12px"); this.hovery = this.svg .append("text") .attr("text-anchor", "end") .attr("font-weight", "bold") .attr("fill", "#000") .attr("font-size", "12px"); this.xlabel = this.wrapper.select(".additive-force-array-xlabel"); this.ylabel = this.wrapper.select(".additive-force-array-ylabel"); // Create our colors and color gradients //Verify custom color map let plot_colors=undefined; if (typeof this.props.plot_cmap === "string") { if (!(this.props.plot_cmap in colors.colors)) { console.log("Invalid color map name, reverting to default."); plot_colors=colors.colors.RdBu; } else { plot_colors = colors.colors[this.props.plot_cmap] } } else if (Array.isArray(this.props.plot_cmap)){ plot_colors = this.props.plot_cmap } this.colors = plot_colors.map(x => hsl(x)); this.brighterColors = [1.45, 1.6].map((v, i) => this.colors[i].brighter(v)); // create our axes let defaultFormat = format(",.4"); if ((this.props.ordering_keys != null) && (this.props.ordering_keys_time_format != null)) { this.parseTime = timeParse(this.props.ordering_keys_time_format); this.formatTime = timeFormat(this.props.ordering_keys_time_format); function condFormat(x) { if (typeof(x) == "object") { return this.formatTime(x); } else { return defaultFormat(x); }; } this.xtickFormat = condFormat; } else { this.parseTime = null; this.formatTime = null; this.xtickFormat = defaultFormat; } this.xscale = scaleLinear(); this.xaxis = axisBottom() .scale(this.xscale) .tickSizeInner(4) .tickSizeOuter(0) .tickFormat(d => this.xtickFormat(d)) .tickPadding(-18); this.ytickFormat = defaultFormat; this.yscale = scaleLinear(); this.yaxis = axisLeft() .scale(this.yscale) .tickSizeInner(4) .tickSizeOuter(0) .tickFormat(d => this.ytickFormat(this.invLinkFunction(d))) .tickPadding(2); this.xlabel.node().onchange = () => this.internalDraw(); this.ylabel.node().onchange = () => this.internalDraw(); this.svg.on("mousemove", event => this.mouseMoved(event)); this.svg.on("click", () => alert("This original index of the sample you clicked is " + this.nearestExpIndex)); this.svg.on("mouseout", x => this.mouseOut(x)); // draw and then listen for resize events //this.draw(); window.addEventListener("resize", this.redraw); window.setTimeout(this.redraw, 50); // re-draw after interface has updated } componentDidUpdate() { this.draw(); } mouseOut() { this.hoverLine.attr("display", "none"); this.hoverx.attr("display", "none"); this.hoverxOutline.attr("display", "none"); this.hoverxTitle.attr("display", "none"); this.hovery.attr("display", "none"); this.hoveryOutline.attr("display", "none"); this.hoverGroup1.attr("display", "none"); this.hoverGroup2.attr("display", "none"); } mouseMoved(event) { let i, nearestExp; this.hoverLine.attr("display", ""); this.hoverx.attr("display", ""); this.hoverxOutline.attr("display", ""); this.hoverxTitle.attr("display", ""); this.hovery.attr("display", ""); this.hoveryOutline.attr("display", ""); this.hoverGroup1.attr("display", ""); this.hoverGroup2.attr("display", ""); let x = pointer(event, this.svg.node())[0]; if (this.props.explanations) { // Find the nearest explanation to the cursor position for (i = 0; i < this.currExplanations.length; ++i) { if ( !nearestExp || Math.abs(nearestExp.xmapScaled - x) > Math.abs(this.currExplanations[i].xmapScaled - x) ) { nearestExp = this.currExplanations[i]; } } this.nearestExpIndex = nearestExp.origInd; this.hoverLine .attr("x1", nearestExp.xmapScaled) .attr("x2", nearestExp.xmapScaled) .attr("y1", 0 + this.topOffset) .attr("y2", this.height); this.hoverx .attr("x", nearestExp.xmapScaled) .attr("y", this.topOffset - 5) .text(this.xtickFormat(nearestExp.xmap)); this.hoverxOutline .attr("x", nearestExp.xmapScaled) .attr("y", this.topOffset - 5) .text(this.xtickFormat(nearestExp.xmap)); this.hoverxTitle .attr("x", nearestExp.xmapScaled) .attr("y", this.topOffset - 18) .text( nearestExp.count > 1 ? nearestExp.count + " averaged samples" : "" ); this.hovery .attr("x", this.leftOffset - 6) .attr("y", nearestExp.joinPointy) .text(this.ytickFormat(this.invLinkFunction(nearestExp.joinPoint))); this.hoveryOutline .attr("x", this.leftOffset - 6) .attr("y", nearestExp.joinPointy) .text(this.ytickFormat(this.invLinkFunction(nearestExp.joinPoint))); let posFeatures = []; let lastPos, pos; for (let j = this.currPosOrderedFeatures.length - 1; j >= 0; --j) { let i = this.currPosOrderedFeatures[j]; let d = nearestExp.features[i]; pos = 5 + (d.posyTop + d.posyBottom) / 2; if ( (!lastPos || pos - lastPos >= 15) && d.posyTop - d.posyBottom >= 6 ) { posFeatures.push(d); lastPos = pos; } } let negFeatures = []; lastPos = undefined; for (let i of this.currNegOrderedFeatures) { let d = nearestExp.features[i]; pos = 5 + (d.negyTop + d.negyBottom) / 2; if ( (!lastPos || lastPos - pos >= 15) && d.negyTop - d.negyBottom >= 6 ) { negFeatures.push(d); lastPos = pos; } } let labelFunc = d => { let valString = ""; if (d.value !== null && d.value !== undefined) { valString = " = " + (isNaN(d.value) ? d.value : this.ytickFormat(d.value)); } if (nearestExp.count > 1) { return "mean(" + this.props.featureNames[d.ind] + ")" + valString; } else { return this.props.featureNames[d.ind] + valString; } }; let featureHoverLabels1 = this.hoverGroup1 .selectAll(".pos-values") .data(posFeatures); featureHoverLabels1 .enter() .append("text") .attr("class", "pos-values") .merge(featureHoverLabels1) .attr("x", nearestExp.xmapScaled + 5) .attr("y", d => 4 + (d.posyTop + d.posyBottom) / 2) .attr("text-anchor", "start") .attr("font-size", 12) .attr("stroke", "#fff") .attr("fill", "#fff") .attr("stroke-width", "4") .attr("stroke-linejoin", "round") .attr("opacity", 1) .text(labelFunc); featureHoverLabels1.exit().remove(); let featureHoverLabels2 = this.hoverGroup2 .selectAll(".pos-values") .data(posFeatures); featureHoverLabels2 .enter() .append("text") .attr("class", "pos-values") .merge(featureHoverLabels2) .attr("x", nearestExp.xmapScaled + 5) .attr("y", d => 4 + (d.posyTop + d.posyBottom) / 2) .attr("text-anchor", "start") .attr("font-size", 12) .attr("fill", this.colors[0]) .text(labelFunc); featureHoverLabels2.exit().remove(); let featureHoverNegLabels1 = this.hoverGroup1 .selectAll(".neg-values") .data(negFeatures); featureHoverNegLabels1 .enter() .append("text") .attr("class", "neg-values") .merge(featureHoverNegLabels1) .attr("x", nearestExp.xmapScaled + 5) .attr("y", d => 4 + (d.negyTop + d.negyBottom) / 2) .attr("text-anchor", "start") .attr("font-size", 12) .attr("stroke", "#fff") .attr("fill", "#fff") .attr("stroke-width", "4") .attr("stroke-linejoin", "round") .attr("opacity", 1) .text(labelFunc); featureHoverNegLabels1.exit().remove(); let featureHoverNegLabels2 = this.hoverGroup2 .selectAll(".neg-values") .data(negFeatures); featureHoverNegLabels2 .enter() .append("text") .attr("class", "neg-values") .merge(featureHoverNegLabels2) .attr("x", nearestExp.xmapScaled + 5) .attr("y", d => 4 + (d.negyTop + d.negyBottom) / 2) .attr("text-anchor", "start") .attr("font-size", 12) .attr("fill", this.colors[1]) .text(labelFunc); featureHoverNegLabels2.exit().remove(); } } draw() { if (!this.props.explanations || this.props.explanations.length === 0) return; // record the order in which the explanations were given each(this.props.explanations, (x, i) => (x.origInd = i)); // Find what features are actually used let posDefinedFeatures = {}; let negDefinedFeatures = {}; let definedFeaturesValues = {}; for (let e of this.props.explanations) { for (let k in e.features) { if (posDefinedFeatures[k] === undefined) { posDefinedFeatures[k] = 0; negDefinedFeatures[k] = 0; definedFeaturesValues[k] = 0; } if (e.features[k].effect > 0) { posDefinedFeatures[k] += e.features[k].effect; } else { negDefinedFeatures[k] -= e.features[k].effect; } if (e.features[k].value !== null && e.features[k].value !== undefined) { definedFeaturesValues[k] += 1; } } } this.usedFeatures = sortBy( keys(posDefinedFeatures), i => -(posDefinedFeatures[i] + negDefinedFeatures[i]) ); console.log("found ", this.usedFeatures.length, " used features"); this.posOrderedFeatures = sortBy( this.usedFeatures, i => posDefinedFeatures[i] ); this.negOrderedFeatures = sortBy( this.usedFeatures, i => -negDefinedFeatures[i] ); this.singleValueFeatures = filter( this.usedFeatures, i => definedFeaturesValues[i] > 0 ); let options = [ "sample order by similarity", "sample order by output value", "original sample ordering" ].concat(this.singleValueFeatures.map(i => this.props.featureNames[i])); if (this.props.ordering_keys != null) { options.unshift("sample order by key"); } let xLabelOptions = this.xlabel.selectAll("option").data(options); xLabelOptions .enter() .append("option") .merge(xLabelOptions) .attr("value", d => d) .text(d => d); xLabelOptions.exit().remove(); let n = this.props.outNames[0] ? this.props.outNames[0] : "model output value"; options = map(this.usedFeatures, i => [ this.props.featureNames[i], this.props.featureNames[i] + " effects" ]); options.unshift(["model output value", n]); let yLabelOptions = this.ylabel.selectAll("option").data(options); yLabelOptions .enter() .append("option") .merge(yLabelOptions) .attr("value", d => d[0]) .text(d => d[1]); yLabelOptions.exit().remove(); this.ylabel .style( "top", (this.height - 10 - this.topOffset) / 2 + this.topOffset + "px" ) .style("left", 10 - this.ylabel.node().offsetWidth / 2 + "px"); this.internalDraw(); } internalDraw() { // we fill in any implicit feature values and assume they have a zero effect and value for (let e of this.props.explanations) { for (let i of this.usedFeatures) { if (!e.features.hasOwnProperty(i)) { e.features[i] = { effect: 0, value: 0 }; } e.features[i].ind = i; } } let explanations; let xsort = this.xlabel.node().value; // Set scaleTime if time ticks provided for original ordering let isTimeScale = ((xsort === "sample order by key") && (this.props.ordering_keys_time_format != null)); if (isTimeScale) { this.xscale = scaleTime(); } else { this.xscale = scaleLinear(); } this.xaxis.scale(this.xscale); if (xsort === "sample order by similarity") { explanations = sortBy(this.props.explanations, x => x.simIndex); each(explanations, (e, i) => (e.xmap = i)); } else if (xsort === "sample order by output value") { explanations = sortBy(this.props.explanations, x => -x.outValue); each(explanations, (e, i) => (e.xmap = i)); } else if (xsort === "original sample ordering") { explanations = sortBy(this.props.explanations, x => x.origInd); each(explanations, (e, i) => (e.xmap = i)); } else if (xsort === "sample order by key") { explanations = this.props.explanations; if (isTimeScale) { each(explanations, (e, i) => (e.xmap = this.parseTime(this.props.ordering_keys[i]))); } else { each(explanations, (e, i) => (e.xmap = this.props.ordering_keys[i])); } explanations = sortBy(explanations, e => e.xmap); } else { let ind = findKey(this.props.featureNames, x => x === xsort); each(this.props.explanations, (e, i) => (e.xmap = e.features[ind].value)); let explanations2 = sortBy(this.props.explanations, x => x.xmap); let xvals = map(explanations2, x => x.xmap); if (typeof xvals[0] == "string") { alert("Ordering by category names is not yet supported."); return; } let xmin = min(xvals); let xmax = max(xvals); let binSize = (xmax - xmin) / 100; // Build explanations where effects are averaged when the x values are identical explanations = []; let laste, copye; for (let i = 0; i < explanations2.length; ++i) { let e = explanations2[i]; if ( (laste && (!copye && e.xmap - laste.xmap <= binSize)) || (copye && e.xmap - copye.xmap <= binSize) ) { if (!copye) { copye = cloneDeep(laste); copye.count = 1; } for (let j of this.usedFeatures) { copye.features[j].effect += e.features[j].effect; copye.features[j].value += e.features[j].value; } copye.count += 1; } else if (laste) { if (copye) { for (let j of this.usedFeatures) { copye.features[j].effect /= copye.count; copye.features[j].value /= copye.count; } explanations.push(copye); copye = undefined; } else { explanations.push(laste); } } laste = e; } if (laste.xmap - explanations[explanations.length - 1].xmap > binSize) { explanations.push(laste); } } // adjust for the correct y-value we are plotting this.currUsedFeatures = this.usedFeatures; this.currPosOrderedFeatures = this.posOrderedFeatures; this.currNegOrderedFeatures = this.negOrderedFeatures; //let filteredFeatureNames = this.props.featureNames; let yvalue = this.ylabel.node().value; if (yvalue !== "model output value") { let olde = explanations; explanations = cloneDeep(explanations); // TODO: add pointer from old explanations which is prop.explanations to new ones let ind = findKey(this.props.featureNames, x => x === yvalue); for (let i = 0; i < explanations.length; ++i) { let v = explanations[i].features[ind]; explanations[i].features = {}; explanations[i].features[ind] = v; olde[i].remapped_version = explanations[i]; } //filteredFeatureNames = [this.props.featureNames[ind]]; this.currUsedFeatures = [ind]; this.currPosOrderedFeatures = [ind]; this.currNegOrderedFeatures = [ind]; } this.currExplanations = explanations; // determine the link function if (this.props.link === "identity") { // assume all links are the same this.invLinkFunction = x => this.props.baseValue + x; } else if (this.props.link === "logit") { this.invLinkFunction = x => 1 / (1 + Math.exp(-(this.props.baseValue + x))); // logistic is inverse of logit } else { console.log("ERROR: Unrecognized link function: ", this.props.link); } this.predValues = map(explanations, e => sum(map(e.features, x => x.effect)) ); let width = this.wrapper.node().offsetWidth; if (width == 0) return setTimeout(() => this.draw(explanations), 500); this.svg.style("height", this.height + "px"); this.svg.style("width", width + "px"); let xvals = map(explanations, x => x.xmap); this.xscale .domain([min(xvals), max(xvals)]) .range([this.leftOffset, width]) .clamp(true); this.xaxisElement .attr("transform", "translate(0," + this.topOffset + ")") .call(this.xaxis); for (let i = 0; i < this.currExplanations.length; ++i) { this.currExplanations[i].xmapScaled = this.xscale( this.currExplanations[i].xmap ); } let N = explanations.length; let domainSize = 0; for (let ind = 0; ind < N; ++ind) { let data2 = explanations[ind].features; //if (data2.length !== P) error("Explanations have differing numbers of features!"); let totalPosEffects = sum(map(filter(data2, x => x.effect > 0), x => x.effect)) || 0; let totalNegEffects = sum(map(filter(data2, x => x.effect < 0), x => -x.effect)) || 0; domainSize = Math.max( domainSize, Math.max(totalPosEffects, totalNegEffects) * 2.2 ); } this.yscale .domain([-domainSize / 2, domainSize / 2]) .range([this.height - 10, this.topOffset]); this.yaxisElement .attr("transform", "translate(" + this.leftOffset + ",0)") .call(this.yaxis); for (let ind = 0; ind < N; ++ind) { let data2 = explanations[ind].features; //console.log(length(data2)) let totalNegEffects = sum(map(filter(data2, x => x.effect < 0), x => -x.effect)) || 0; //let scaleOffset = height/2 - this.yscale(totalNegEffects); // calculate the position of the join point between positive and negative effects // and also the positions of each feature effect block let pos = -totalNegEffects, i; for (i of this.currPosOrderedFeatures) { data2[i].posyTop = this.yscale(pos); if (data2[i].effect > 0) pos += data2[i].effect; data2[i].posyBottom = this.yscale(pos); data2[i].ind = i; } let joinPoint = pos; for (i of this.currNegOrderedFeatures) { data2[i].negyTop = this.yscale(pos); if (data2[i].effect < 0) pos -= data2[i].effect; data2[i].negyBottom = this.yscale(pos); } explanations[ind].joinPoint = joinPoint; explanations[ind].joinPointy = this.yscale(joinPoint); } let lineFunction = line() .x(d => d[0]) .y(d => d[1]); let areasPos = this.mainGroup .selectAll(".force-bar-array-area-pos") .data(this.currUsedFeatures); areasPos .enter() .append("path") .attr("class", "force-bar-array-area-pos") .merge(areasPos) .attr("d", i => { let topPoints = map(range(N), j => [ explanations[j].xmapScaled, explanations[j].features[i].posyTop ]); let bottomPoints = map(rangeRight(N), j => [ explanations[j].xmapScaled, explanations[j].features[i].posyBottom ]); return lineFunction(topPoints.concat(bottomPoints)); }) .attr("fill", this.colors[0]); areasPos.exit().remove(); let areasNeg = this.mainGroup .selectAll(".force-bar-array-area-neg") .data(this.currUsedFeatures); areasNeg .enter() .append("path") .attr("class", "force-bar-array-area-neg") .merge(areasNeg) .attr("d", i => { let topPoints = map(range(N), j => [ explanations[j].xmapScaled, explanations[j].features[i].negyTop ]); let bottomPoints = map(rangeRight(N), j => [ explanations[j].xmapScaled, explanations[j].features[i].negyBottom ]); return lineFunction(topPoints.concat(bottomPoints)); }) .attr("fill", this.colors[1]); areasNeg.exit().remove(); let dividersPos = this.mainGroup .selectAll(".force-bar-array-divider-pos") .data(this.currUsedFeatures); dividersPos .enter() .append("path") .attr("class", "force-bar-array-divider-pos") .merge(dividersPos) .attr("d", i => { let points = map(range(N), j => [ explanations[j].xmapScaled, explanations[j].features[i].posyBottom ]); return lineFunction(points); }) .attr("fill", "none") .attr("stroke-width", 1) .attr("stroke", () => this.colors[0].brighter(1.2)); dividersPos.exit().remove(); let dividersNeg = this.mainGroup .selectAll(".force-bar-array-divider-neg") .data(this.currUsedFeatures); dividersNeg .enter() .append("path") .attr("class", "force-bar-array-divider-neg") .merge(dividersNeg) .attr("d", i => { let points = map(range(N), j => [ explanations[j].xmapScaled, explanations[j].features[i].negyTop ]); return lineFunction(points); }) .attr("fill", "none") .attr("stroke-width", 1) .attr("stroke", () => this.colors[1].brighter(1.5)); dividersNeg.exit().remove(); let boxBounds = function(es, ind, starti, endi, featType) { let maxTop, minBottom; if (featType === "pos") { maxTop = es[starti].features[ind].posyBottom; minBottom = es[starti].features[ind].posyTop; } else { maxTop = es[starti].features[ind].negyBottom; minBottom = es[starti].features[ind].negyTop; } let t, b; for (let i = starti + 1; i <= endi; ++i) { if (featType === "pos") { t = es[i].features[ind].posyBottom; b = es[i].features[ind].posyTop; } else { t = es[i].features[ind].negyBottom; b = es[i].features[ind].negyTop; } if (t > maxTop) maxTop = t; if (b < minBottom) minBottom = b; } return { top: maxTop, bottom: minBottom }; }; let neededWidth = 100; let neededHeight = 20; let neededBuffer = 100; // find areas on the plot big enough for feature labels let featureLabels = []; for (let featType of ["pos", "neg"]) { for (let ind of this.currUsedFeatures) { let starti = 0, endi = 0, boxWidth = 0, hbounds = { top: 0, bottom: 0 }; let newHbounds; while (endi < N - 1) { // make sure our box is long enough while (boxWidth < neededWidth && endi < N - 1) { ++endi; boxWidth = explanations[endi].xmapScaled - explanations[starti].xmapScaled; } // and high enough hbounds = boxBounds(explanations, ind, starti, endi, featType); while (hbounds.bottom - hbounds.top < neededHeight && starti < endi) { ++starti; hbounds = boxBounds(explanations, ind, starti, endi, featType); } boxWidth = explanations[endi].xmapScaled - explanations[starti].xmapScaled; // we found a spot! if ( hbounds.bottom - hbounds.top >= neededHeight && boxWidth >= neededWidth ) { //console.log(`found a spot! ind: ${ind}, starti: ${starti}, endi: ${endi}, hbounds:`, hbounds) // make our box as long as possible while (endi < N - 1) { ++endi; newHbounds = boxBounds(explanations, ind, starti, endi, featType); if (newHbounds.bottom - newHbounds.top > neededHeight) { hbounds = newHbounds; } else { --endi; break; } } boxWidth = explanations[endi].xmapScaled - explanations[starti].xmapScaled; //console.log("found ",boxWidth,hbounds) featureLabels.push([ (explanations[endi].xmapScaled + explanations[starti].xmapScaled) / 2, (hbounds.top + hbounds.bottom) / 2, this.props.featureNames[ind] ]); let lastEnd = explanations[endi].xmapScaled; starti = endi; while ( lastEnd + neededBuffer > explanations[starti].xmapScaled && starti < N - 1 ) { ++starti; } endi = starti; } } } } let featureLabelText = this.onTopGroup .selectAll(".force-bar-array-flabels") .data(featureLabels); featureLabelText .enter() .append("text") .attr("class", "force-bar-array-flabels") .merge(featureLabelText) .attr("x", d => d[0]) .attr("y", d => d[1] + 4) .text(d => d[2]); featureLabelText.exit().remove(); } componentWillUnmount() { window.removeEventListener("resize", this.redraw); } render() { return (