-
Notifications
You must be signed in to change notification settings - Fork 339
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Remove dependencies on commons-math3
and ssj
by implementing simple linear regression directly, replacing the build duration distribution chart with a histogram, and deleting the smoothed trend lines in the test history charts
#639
Changes from 3 commits
a5fef6a
23c682e
dc85191
88552fc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -46,11 +46,9 @@ | |
import java.util.concurrent.atomic.AtomicInteger; | ||
import java.util.stream.Collectors; | ||
import jenkins.util.SystemProperties; | ||
import org.apache.commons.math3.stat.regression.SimpleRegression; | ||
import org.kohsuke.accmod.Restricted; | ||
import org.kohsuke.accmod.restrictions.NoExternalUse; | ||
import org.kohsuke.stapler.bind.JavaScriptMethod; | ||
import umontreal.ssj.functionfit.SmoothingCubicSpline; | ||
|
||
/** | ||
* History of {@link hudson.tasks.test.TestObject} over time. | ||
|
@@ -209,16 +207,6 @@ | |
0, | ||
0, | ||
roundMul); // "--blue" | ||
createSplineTrend( | ||
series, | ||
history, | ||
lrX, | ||
lrY, | ||
"Smooth of " + durationStr, | ||
"rgba(120, 50, 255, 0.5)", | ||
0, | ||
0, | ||
roundMul); // "--indigo" | ||
} | ||
root.set("series", series); | ||
root.set("domainAxisLabels", domainAxisLabels); | ||
|
@@ -249,12 +237,9 @@ | |
return; | ||
} | ||
|
||
SimpleRegression sr = new SimpleRegression(true); | ||
for (int i = 0; i < lrX.length; i++) { | ||
sr.addData(lrX[i], lrY[i]); | ||
} | ||
double intercept = sr.getIntercept(); | ||
double slope = sr.getSlope(); | ||
double[] cs = SimpleLinearRegression.coefficients(lrX, lrY); | ||
double intercept = cs[0]; | ||
double slope = cs[1]; | ||
|
||
ObjectNode lrSeries = MAPPER.createObjectNode(); | ||
series.add(lrSeries); | ||
|
@@ -283,51 +268,6 @@ | |
} | ||
} | ||
|
||
private void createSplineTrend( | ||
ArrayNode series, | ||
List<HistoryTestResultSummary> history, | ||
double[] lrX, | ||
double[] lrY, | ||
String title, | ||
String color, | ||
int xAxisIndex, | ||
int yAxisIndex, | ||
double roundMul) { | ||
if (history.size() < 200) { | ||
return; | ||
} | ||
double rho = Math.min(1.0, 1.0 / Math.max(1, (history.size() / 2))); | ||
if (rho > 0.75) { | ||
return; // Too close to linear | ||
} | ||
SmoothingCubicSpline scs = new SmoothingCubicSpline(lrX, lrY, 0.001); | ||
ObjectNode lrSeries = MAPPER.createObjectNode(); | ||
series.add(lrSeries); | ||
lrSeries.put("name", title); | ||
lrSeries.put("preferScreenOrient", "landscape"); | ||
lrSeries.put("type", "line"); | ||
lrSeries.put("symbol", "circle"); | ||
lrSeries.put("symbolSize", 0); | ||
lrSeries.put("xAxisIndex", xAxisIndex); | ||
lrSeries.put("yAxisIndex", yAxisIndex); | ||
ArrayNode lrData = MAPPER.createArrayNode(); | ||
lrSeries.set("data", lrData); | ||
ObjectNode lrStyle = MAPPER.createObjectNode(); | ||
lrSeries.set("itemStyle", lrStyle); | ||
lrStyle.put("color", color); | ||
ObjectNode lrAreaStyle = MAPPER.createObjectNode(); | ||
lrSeries.set("areaStyle", lrAreaStyle); | ||
lrAreaStyle.put("color", "rgba(0, 0, 0, 0)"); | ||
|
||
if (roundMul < 10.0) { | ||
roundMul = 10.0; | ||
} | ||
for (int index = 0; index < history.size(); ++index) { | ||
// Use float to reduce JSON size. | ||
lrData.add((float) (Math.round(scs.evaluate(index) * roundMul) / roundMul)); | ||
} | ||
} | ||
|
||
static boolean EXTRA_GRAPH_MATH_ENABLED = | ||
Boolean.parseBoolean(System.getProperty(History.class.getName() + ".EXTRA_GRAPH_MATH_ENABLED", "true")); | ||
|
||
|
@@ -468,8 +408,6 @@ | |
1, | ||
1, | ||
10.0); // "--dark-blue" | ||
createSplineTrend( | ||
series, history, lrX, lrY, "Smooth of Passed", "rgba(255, 50, 255, 0.5)", 1, 1, 10.0); // "--purple" | ||
} | ||
|
||
root.set("series", series); | ||
|
@@ -483,24 +421,16 @@ | |
private ObjectNode computeDistributionJson(List<HistoryTestResultSummary> history) { | ||
ObjectNode root = MAPPER.createObjectNode(); | ||
ArrayNode series = MAPPER.createArrayNode(); | ||
ArrayNode domainAxisLabels = MAPPER.createArrayNode(); | ||
|
||
ObjectNode durationSeries = MAPPER.createObjectNode(); | ||
durationSeries.put("name", "Build Count"); | ||
durationSeries.put("type", "line"); | ||
durationSeries.put("symbol", "circle"); | ||
durationSeries.put("symbolSize", "0"); | ||
durationSeries.put("sampling", "lttb"); | ||
durationSeries.put("type", "bar"); | ||
durationSeries.put("barWidth", "99%"); | ||
ArrayNode durationData = MAPPER.createArrayNode(); | ||
durationSeries.set("data", durationData); | ||
ObjectNode durationStyle = MAPPER.createObjectNode(); | ||
durationSeries.set("itemStyle", durationStyle); | ||
durationStyle.put("color", "--success-color"); // "rgba(50, 200, 50, 0.8)"); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This color is a bit ugly now that it is not just a simple line in case anyone has any recommendations. I think ideally this would be a stacked histogram chart with each build result shown in a different color. |
||
durationSeries.put("stack", "stacked"); | ||
ObjectNode durAreaStyle = MAPPER.createObjectNode(); | ||
durationSeries.set("areaStyle", durAreaStyle); | ||
durAreaStyle.put("color", "rgba(0,0,0,0)"); | ||
durationSeries.put("smooth", true); | ||
series.add(durationSeries); | ||
|
||
double maxDuration = 0, minDuration = Double.MAX_VALUE; | ||
|
@@ -512,22 +442,20 @@ | |
minDuration = h.getDuration(); | ||
} | ||
} | ||
double extraDuration = Math.max(0.001, (maxDuration - minDuration) * 0.05); | ||
minDuration = Math.max(0.0, minDuration - extraDuration); | ||
maxDuration = maxDuration + extraDuration; | ||
int[] counts = new int[100]; | ||
int smoothBuffer = 2; | ||
double[] lrX = new double[counts.length + smoothBuffer * 2 + 1]; | ||
double[] lrY = new double[counts.length + smoothBuffer * 2 + 1]; | ||
double scale = maxDuration - minDuration; | ||
double step = scale / counts.length; | ||
// double extraDuration = Math.max(0.001, (maxDuration - minDuration) * 0.05); | ||
dwnusbaum marked this conversation as resolved.
Show resolved
Hide resolved
|
||
minDuration = Math.max(0.0, minDuration); | ||
int buckets = 50; | ||
double[] lrX = new double[buckets]; | ||
double[] lrY = new double[buckets]; | ||
double domain = maxDuration - minDuration; | ||
double step = domain / buckets; | ||
for (HistoryTestResultSummary h : history) { | ||
int idx = smoothBuffer + (int) Math.round((h.getDuration() - minDuration) / step); | ||
int idx = (int) Math.round((h.getDuration() - minDuration) / step); | ||
int idx2 = Math.max(0, Math.min(idx, lrY.length - 1)); | ||
lrY[idx2]++; | ||
} | ||
for (int i = 0; i < lrY.length; ++i) { | ||
lrX[i] = ((minDuration + (maxDuration - minDuration) / lrY.length * i) / scale * 100.0); | ||
lrX[i] = minDuration + step * (i + 0.5); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Offset the X values so they are in the middle of the bucket. We are no longer scaling them into the range 0-1 because we are using them directly in our data series. |
||
} | ||
|
||
ObjectNode xAxis = MAPPER.createObjectNode(); | ||
|
@@ -551,24 +479,25 @@ | |
mul = 1.0d / 3600.0d; | ||
roundMul = 100.0; | ||
} | ||
xAxis.put("min", (float) (mul * lrX[0] - (step * mul * 0.5))); | ||
xAxis.put("max", (float) (mul * lrX[lrX.length - 1] + (step * mul * 0.5))); | ||
xAxis.put("interval", (float) (step * mul)); | ||
xAxis.put("roundingFactor", (float) roundMul); | ||
|
||
int maxBuilds = 0; | ||
SmoothingCubicSpline scs = new SmoothingCubicSpline(lrX, lrY, 0.1); | ||
int smoothPts = counts.length * 4; | ||
double k = (double) counts.length / smoothPts; | ||
final double splineRoundMul = 1000.0; | ||
for (double z = minDuration; z < maxDuration; z += step * k) { | ||
double v = Math.round(splineRoundMul * Math.max(0.0, scs.evaluate(z / scale * 100.0))) / splineRoundMul; | ||
durationData.add((float) v); | ||
maxBuilds = Math.max(maxBuilds, (int) Math.ceil(v)); | ||
// Use float for smaller JSONs. | ||
domainAxisLabels.add((float) (Math.round(mul * z * roundMul) / roundMul)); | ||
for (int i = 0; i < lrY.length; i++) { | ||
double scaledX = mul * lrX[i]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We no longer round our X values in the backend ( |
||
double y = lrY[i]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nothing fancy with the Y values anymore, this is just the frequency of build durations in this bucket. We can probably make |
||
ArrayNode data = MAPPER.createArrayNode(); | ||
data.add((float) scaledX); | ||
data.add((float) y); | ||
durationData.add(data); | ||
maxBuilds = Math.max(maxBuilds, (int) Math.ceil(y)); | ||
} | ||
|
||
root.set("series", series); | ||
root.put("integerRangeAxis", true); | ||
root.put("domainAxisItemName", "Number of Builds"); | ||
root.set("domainAxisLabels", domainAxisLabels); | ||
root.set("xAxis", xAxis); | ||
if (maxBuilds >= 10) { | ||
root.put("rangeMax", maxBuilds); | ||
|
@@ -813,4 +742,36 @@ | |
return defaultValue; | ||
} | ||
} | ||
|
||
// https://en.wikipedia.org/wiki/Simple_linear_regression | ||
static class SimpleLinearRegression { | ||
static double[] coefficients(double[] xs, double[] ys) { | ||
int n = xs.length; | ||
if (n < 2) { | ||
throw new IllegalArgumentException("At least two data points are required, but got: " + xs.length); | ||
} | ||
if (xs.length != ys.length) { | ||
throw new IllegalArgumentException("Array lengths do not match:" + xs.length + " vs " + ys.length); | ||
} | ||
double sumX = 0; | ||
double sumY = 0; | ||
double sumXX = 0; | ||
double sumXY = 0; | ||
for (int i = 0; i < n; i++) { | ||
double x = xs[i]; | ||
double y = ys[i]; | ||
sumX += x; | ||
sumY += y; | ||
sumXX += x * x; | ||
sumXY += x * y; | ||
} | ||
if (Math.abs(sumXX) < 10 * Double.MIN_VALUE) { | ||
// Avoid returning +/- infinity in case the x values are too close together. | ||
return new double[] {Double.NaN, Double.NaN}; | ||
} | ||
double slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX); | ||
double intercept = sumY / n - slope / n * sumX; | ||
return new double[] {intercept, slope}; | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -323,12 +323,16 @@ function onBuildIntervalChange(selectObj) { | |
top: '20%', | ||
}, | ||
xAxis: { | ||
type: 'category', | ||
boundaryGap: false, | ||
type: 'value', | ||
axisLabel: { | ||
color: textColor | ||
color: textColor, | ||
formatter: function(value) { | ||
return Math.round(value * model.distribution.xAxis.roundingFactor) / model.distribution.xAxis.roundingFactor; | ||
Comment on lines
+329
to
+330
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Round the labels in the frontend. |
||
} | ||
}, | ||
data: model.distribution.domainAxisLabels, | ||
min: model.distribution.xAxis.min, | ||
max: model.distribution.xAxis.max, | ||
minInterval: model.distribution.xAxis.interval, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What I really wanted was to be able to tell If you set I also wanted to have dynamic |
||
name: model.distribution.xAxis.name, | ||
nameLocation: 'middle', | ||
nameGap: 26, | ||
|
@@ -343,6 +347,7 @@ function onBuildIntervalChange(selectObj) { | |
axisLabel: { | ||
color: textColor | ||
}, | ||
minInterval: model.result.integerRangeAxis ? 1 : null, | ||
name: 'Build Count', | ||
nameLocation: 'middle', | ||
nameGap: 60, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
package hudson.tasks.junit; | ||
|
||
import static org.hamcrest.MatcherAssert.assertThat; | ||
import static org.hamcrest.Matchers.closeTo; | ||
import static org.hamcrest.Matchers.containsString; | ||
import static org.hamcrest.Matchers.notANumber; | ||
import static org.junit.Assert.assertThrows; | ||
|
||
import hudson.tasks.junit.History.SimpleLinearRegression; | ||
import org.junit.Test; | ||
|
||
public class SimpleLinearRegressionTest { | ||
|
||
@Test | ||
public void smokes() { | ||
// Results checked in Excel. | ||
double[] xs = {2, 3, 4, 5, 6, 8, 10, 11}; | ||
double[] ys = {21.05, 23.51, 24.23, 27.71, 30.86, 45.85, 52.12, 55.98}; | ||
double[] cs = SimpleLinearRegression.coefficients(xs, ys); | ||
assertThat(cs[0], closeTo(9.4763, 0.0001)); | ||
assertThat(cs[1], closeTo(4.1939, 0.0001)); | ||
|
||
xs = new double[] {1.47, 1.5, 1.52, 1.55, 1.57, 1.6, 1.63, 1.65, 1.68, 1.7, 1.73, 1.75, 1.78, 1.8, 1.83}; | ||
ys = new double[] { | ||
52.21, 53.12, 54.48, 55.84, 57.2, 58.57, 59.93, 61.29, 63.11, 64.47, 66.28, 68.1, 69.92, 72.19, 74.46 | ||
}; | ||
cs = SimpleLinearRegression.coefficients(xs, ys); | ||
assertThat(cs[0], closeTo(-39.0620, 0.0001)); | ||
assertThat(cs[1], closeTo(61.2722, 0.0001)); | ||
} | ||
|
||
@Test | ||
public void requires2DataPoints() { | ||
var t = assertThrows( | ||
IllegalArgumentException.class, | ||
() -> SimpleLinearRegression.coefficients(new double[0], new double[0])); | ||
assertThat(t.getMessage(), containsString("At least two data points are required")); | ||
t = assertThrows( | ||
IllegalArgumentException.class, | ||
() -> SimpleLinearRegression.coefficients(new double[1], new double[1])); | ||
assertThat(t.getMessage(), containsString("At least two data points are required")); | ||
double[] cs = SimpleLinearRegression.coefficients(new double[] {0.0, 1.0}, new double[] {1.0, 1.0}); | ||
assertThat(cs[0], closeTo(1.0, 0.001)); | ||
assertThat(cs[1], closeTo(0, 0.001)); | ||
} | ||
|
||
@Test | ||
public void requiresArraysWithSameLength() { | ||
var t = assertThrows( | ||
IllegalArgumentException.class, | ||
() -> SimpleLinearRegression.coefficients(new double[3], new double[4])); | ||
assertThat(t.getMessage(), containsString("Array lengths do not match")); | ||
t = assertThrows( | ||
IllegalArgumentException.class, | ||
() -> SimpleLinearRegression.coefficients(new double[4], new double[3])); | ||
assertThat(t.getMessage(), containsString("Array lengths do not match")); | ||
} | ||
|
||
@Test | ||
public void returnsNanIfXValuesDoNotVaryEnough() { | ||
double[] xs = {Double.MIN_VALUE, 1e162 * Double.MIN_VALUE}; | ||
double[] ys = {0.0, 1.0}; | ||
double[] cs = SimpleLinearRegression.coefficients(xs, ys); | ||
assertThat(cs[0], notANumber()); | ||
assertThat(cs[1], notANumber()); | ||
|
||
xs = new double[] {Double.MIN_VALUE, 1e163 * Double.MIN_VALUE}; | ||
ys = new double[] {0.0, 1.0}; | ||
cs = SimpleLinearRegression.coefficients(xs, ys); | ||
assertThat(cs[0], closeTo(0.0, 0.001)); | ||
assertThat(cs[1], closeTo(2.0e160, 1e159)); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Slightly less than 100% so you see a border between each bar.