Skip to content
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

Merged
merged 4 commits into from
Aug 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 0 additions & 17 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,6 @@
</dependencyManagement>

<dependencies>
<dependency>
<groupId>ca.umontreal.iro.simul</groupId>
<artifactId>ssj</artifactId>
<version>3.3.2</version>
<!-- Dependencies are unused, provided by Jenkins core, have unusual licenses, unclear artifact ownership, and/or are obsolete. We only use SmoothingCubicSpline, which does not require dependencies. -->
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.pivovarit</groupId>
<artifactId>parallel-collectors</artifactId>
Expand All @@ -96,11 +84,6 @@
<groupId>io.jenkins.plugins</groupId>
<artifactId>plugin-util-api</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-math3</artifactId>
<version>3.6.1</version>
</dependency>
<dependency>
<groupId>org.jenkins-ci.plugins</groupId>
<artifactId>display-url-api</artifactId>
Expand Down
154 changes: 57 additions & 97 deletions src/main/java/hudson/tasks/junit/History.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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"));

Expand Down Expand Up @@ -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);
Expand All @@ -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%");
Copy link
Member Author

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.

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)");
Copy link
Member Author

@dwnusbaum dwnusbaum Aug 20, 2024

Choose a reason for hiding this comment

The 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;
Expand All @@ -512,22 +442,19 @@
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;
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);
Copy link
Member Author

Choose a reason for hiding this comment

The 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();
Expand All @@ -551,24 +478,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];
Copy link
Member Author

Choose a reason for hiding this comment

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

We no longer round our X values in the backend (z in the old code), because our axis type is now value instead of category, and so if we round here our bars will have weird gaps and not be contiguous.

double y = lrY[i];
Copy link
Member Author

Choose a reason for hiding this comment

The 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 lrY an int[] now.

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);
Expand Down Expand Up @@ -813,4 +741,36 @@
return defaultValue;
}
}

// https://en.wikipedia.org/wiki/Simple_linear_regression
static class SimpleLinearRegression {

Check warning on line 746 in src/main/java/hudson/tasks/junit/History.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 746 is not covered by tests
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};
}
}
}
13 changes: 9 additions & 4 deletions src/main/resources/hudson/tasks/junit/History/history.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member Author

Choose a reason for hiding this comment

The 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,
Copy link
Member Author

Choose a reason for hiding this comment

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

What I really wanted was to be able to tell echarts to pick an interval that is a multiple of model.distribution.xAxis.interval, and then let it automatically choose which multiple based on screen and label size, but I didn't see any way to do it.

If you set interval: model.distribution.xAxis.interval, then you get one grid line per bucket, which is nice, but the labels are unreadably close to each other and I couldn't find a way to fix that dynamically. You can set interval: model.distribution.xAxis.interval * 2 and things look fine on "normalish" laptop screen size, but again it doesn't scale at all if you shrink your screen size.

I also wanted to have dynamic minorTick support, e.g. if we let echarts pick the interval, could we at least set up the minor ticks to align with the buckets? Unfortunately I don't think so, you can only control how many minor ticks you want between each major tick, e.g. if you specify interval: model.distribution.xAxis.interval * 3, you could specify minorTick { show: true, splitNumber: 3 }, but again that doesn't adjust based on the data or screen size at all.

name: model.distribution.xAxis.name,
nameLocation: 'middle',
nameGap: 26,
Expand All @@ -343,6 +347,7 @@ function onBuildIntervalChange(selectObj) {
axisLabel: {
color: textColor
},
minInterval: model.result.integerRangeAxis ? 1 : null,
name: 'Build Count',
nameLocation: 'middle',
nameGap: 60,
Expand Down
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));
}
}