Skip to content

Instantly share code, notes, and snippets.

@Kcnarf
Last active December 3, 2018 08:34
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Kcnarf/0a8fe1caa2ac025c8e86 to your computer and use it in GitHub Desktop.
Save Kcnarf/0a8fe1caa2ac025c8e86 to your computer and use it in GitHub Desktop.
timeline - trend & seasonality
license: mit

An example of how seasonality impacts the trend.

Seasonality means that the time serie has a periodic component, repeating the same pattern on each period. For example, sales of a store may have a week-based seasonality: sales increase on saturday, while there is no sale at all on sunday.

In this example, the season has a length of 4: the first value is high, and the last one is low. Hence, each season has an internal negative trend, which lowers the global trend. A positive internal trend would increase the global trend.

The impact of the season's internal trend on the global trend is higher when :

  • the order of magnitude of the seasonilaty is high (ie. lowest and highest values are far from season's mean)
  • the season's length and the time serie's length are of the same order

In this example, we mitigate seasonality with a windowed approach: for each season, we retain the mean of the season (big dots). This produces a deseasonalized time serie. The deseasonalized global trend (light blue line) is then computed based on this deseasonalized time serie.

Seasonality can be mitigated by various approaches, such as simple linear regression, windowed mean (used in this example), moving mean, smoothing, ...

Usages :

  • Drag & Drop each point to see the impact on the trend line
  • inverse seasonality (in each season, the higher value becomes the lower, and vice versa) to inverse the season's internal trend, and hence inverse the impact on the global trend
  • permute seasonality (in each season, the n-th value becomes the (n-1)-th value and the first one becomes the last one) to lower/increase the season's internal trend (because extremum values are no longer at the begining and end of each season), and hence lower/increase the impact on the global trend

Notes:

  • trend line computed using least square method

Acknowledgments:

<!DOCTYPE html>
<meta charset="utf-8">
<style>
#controls{
position: absolute;
right: 0px;
}
.grid>line, .grid>.intersect {
fill: none;
stroke: #ddd;
shape-rendering: crispEdges;
vector-effect: non-scaling-stroke;
}
.axis path,
.axis line {
fill: none;
stroke: black;
shape-rendering: crispEdges;
}
.axis text {
font-family: sans-serif;
font-size: 11px;
}
.legend {
font-size: 12px;
}
.dot {
fill: lightsteelblue;
stroke: white;
stroke-width: 3px;
}
.dot.deseasonalized {
fill: lightsteelblue;
stroke: white;
stroke-width: 3px;
opacity: 0.2;
}
.dot.draggable:hover, .dot.dragging {
fill: pink;
cursor: ns-resize;
}
.timeline {
fill: none;
stroke: steelblue;
stroke-width: 2px;
opacity: 0.2;
}
.timeline.draggable:hover, .timeline.dragging {
stroke: pink;
opacity: 1;
cursor: ns-resize;
}
.trend {
stroke: steelblue;
}
.trend.deseasonalized {
stroke-opacity: 0.5;
}
</style>
<body>
<div id="controls">
<button onclick="invertTrend();">invert trend</button>
<button onclick="invertSeasonality();">invert seasonality</button>
<button onclick="permuteSeasonality();">permute seasonality</button>
</div>
<script src="https://d3js.org/d3.v3.min.js"></script>
<script>
var timeSerie = [];
var deseasonalizedTimeSerie = [];
var seasonLength = 4; //for a sake of simplicity, this parameter is static, but technics allow to find seasonality from raw time serie (eg. [fast] Fourier transformation)
var seasonCount = 5; //timeSerie.length/seasonLength
var WITH_TRANSITION = true;
var WITHOUT_TRANSITION = false
var duration = 500;
var legendHeight = 20;
var xAxisLabelHeight = 20;
var yAxisLabelWidth = 20;
var margin = {top: 20, right: 20, bottom: 20, left: 20},
width = 960 - margin.left - margin.right - yAxisLabelWidth,
height = 500 - margin.top - margin.bottom - xAxisLabelHeight - legendHeight;
var drag = d3.behavior.drag()
.origin(function(d) { return d; })
.on("dragstart", dragStarted)
.on("drag", dragged)
.on("dragend", dragEnded);
var x = d3.scale.linear()
.domain([0, 20])
.range([0, width])
var y = d3.scale.linear()
.domain([0, 50])
.range([0, -height])
var xAxisDef = d3.svg.axis()
.scale(x)
.ticks(21);
var yAxisDef = d3.svg.axis()
.scale(y)
.orient("left");
var svg = d3.select("body").append("svg")
.attr("width", width + margin.left + margin.right + yAxisLabelWidth)
.attr("height", height + margin.top + margin.bottom + xAxisLabelHeight + legendHeight)
.append("g")
.attr("transform", "translate(" + (margin.left) + "," + (height+margin.top) + ")")
var container = svg.append("g");
var grid = container.append("g")
.attr("class", "grid");
var intersects = [];
d3.range(1, x.invert(width)).forEach(function(a) { d3.range(5, y.invert(-height),5).forEach(function(b) { intersects.push([a,b])})});
grid.selectAll(".intersect")
.data(intersects)
.enter().append("path")
.classed("intersect", true)
.attr("d", function(d) { return "M"+[x(d[0])-1,y(d[1])]+"h3M"+[x(d[0]),y(d[1])-1]+"v3"});
grid.selectAll(".x-line")
.data(d3.range(0.5, x.invert(width), 4))
.enter().append("line")
.classed("x-line", true)
.attr("x1", function(d) { return x(d); })
.attr("y1", y(0))
.attr("x2", function(d) { return x(d); })
.attr("y2", y(49));
container.selectAll(".season-id")
.data(d3.range(0, 4))
.enter().append("text")
.attr("x", function(d) { return x(2.5+d*seasonLength); })
.attr("y", -6)
.style("text-anchor", "middle")
.text(function(d) { return (d!=3)? "season "+ (d+1) : "..."; });
container.append("g")
.attr("class", "axis x")
.call(xAxisDef);
container.append("text")
.attr("x", width)
.attr("y", -6)
.style("text-anchor", "end")
.text("Time");
container.append("g")
.attr("class", "axis y")
.call(yAxisDef);
container.append("text")
.attr("x", 6)
.attr("y", -height+10)
.style("text-anchor", "start")
.text("Amount");
var legend = container.append("g")
.classed("legend", true)
.attr("transform", "translate(" + 100 + "," + (xAxisLabelHeight+legendHeight) + ")");
var currentLegend = legend.append("g")
.attr("transform", "translate(" + 0 + ",0)");
currentLegend.append("circle")
.classed("dot", true)
.attr("r", 4)
.attr("cx", -5)
.attr("cy", -5);
currentLegend.append("text")
.attr("dx", 5)
.text(": raw value");
currentLegend = legend.append("g")
.attr("transform", "translate(" + 130 + ",0)");
currentLegend.append("circle")
.classed("dot deseasonalized", true)
.attr("r", 8)
.attr("cx", -5)
.attr("cy", -5);
currentLegend.append("text")
.attr("dx", 5)
.text(": deseasonalized value (season's mean)");
currentLegend = legend.append("g")
.attr("transform", "translate(" + 400 + ",0)");
currentLegend.append("line")
.classed("trend", true)
.attr("x1", -20)
.attr("y1", -5)
.attr("x2", -5)
.attr("y2", -5);
currentLegend.append("text")
.attr("dx", 5)
.text(": trend of raw time serie");
currentLegend = legend.append("g")
.attr("transform", "translate(" + 600 + ",0)");
currentLegend.append("line")
.classed("trend deseasonalized", true)
.attr("x1", -20)
.attr("y1", -5)
.attr("x2", -5)
.attr("y2", -5);
currentLegend.append("text")
.attr("dx", 5)
.text(": trend of deseasonalized time serie");
var timeline = container.append("path")
.classed("timeline", true)
.attr("d", line);
var dotContainer = container.append("g")
.classed("dots", true);
var deseasonalizedDotContainer = container.append("g")
.classed("dots deseasonalized", true);
var trendLine = container.append("line")
.attr("class", "trend")
.attr("x1", x(0))
.attr("y1", y(0))
.attr("x2", x(20))
.attr("y2", y(0));
var deseasonalizedTrendLine = container.append("line")
.attr("class", "trend deseasonalized")
.attr("x1", x(0))
.attr("y1", y(0))
.attr("x2", x(20))
.attr("y2", y(0));
d3.csv("timeserie.csv", dottype, function(error, dots) {
updateTimeline(WITHOUT_TRANSITION);
updateDots(WITHOUT_TRANSITION);
updateTrend(WITHOUT_TRANSITION);
updateDeseasonalizedTimeSerie();
updateDeseasonalizedDots(WITHOUT_TRANSITION);
updateDeseasonalizedTrend(WITHOUT_TRANSITION);
});
function dottype(d) {
d.x = +d.x;
d.y = +d.y;
timeSerie.push(d);
return d;
}
var line = d3.svg.line()
.x(function(d) { return x(d.x); })
.y(function(d) { return y(d.y); });
function updateDots(withTransition) {
dots = dotContainer.selectAll(".dot")
.data(timeSerie);
dots.enter()
.append("circle")
.classed("dot draggable", true)
.attr("r", 5)
.call(drag);
dots.transition()
.duration(withTransition? duration : 0)
.attr("cx", function(d) { return x(d.x); })
.attr("cy", function(d) { return y(d.y); })
}
function updateTimeline(withTransition) {
timeline.data(timeSerie).transition()
.duration(withTransition? duration : 0)
.attr("d", line(timeSerie));
}
function updateDeseasonalizedDots(withTransition){
var deseasonalizeDots = deseasonalizedDotContainer.selectAll(".dot.deseasonalized")
.data(deseasonalizedTimeSerie);
deseasonalizeDots.enter()
.append("circle")
.classed("dot deseasonalized", true)
.attr("r", 8)
.attr("cx", function(d) { return x(d.x); });
deseasonalizeDots.transition()
.duration(withTransition? duration : 0)
.attr("cy", function(d) { return y(d.y); });
}
function invertTrend() {
var serieLength = timeSerie.length;
var countSum = 0;
var mean = 0;
timeSerie.forEach(function (d) {
countSum += d.y
});
mean = countSum/serieLength;
timeSerie.forEach(function (d) {
d.y = (mean-d.y)+mean;
});
updateDeseasonalizedTimeSerie();
updateTimeline(WITH_TRANSITION);
updateDots(WITH_TRANSITION);
updateDeseasonalizedDots(WITH_TRANSITION);
updateTrend(WITH_TRANSITION);
updateDeseasonalizedTrend(WITH_TRANSITION);
}
function invertSeasonality() {
//objective: for each season, make the inverse with regards to the season's mean
var i = 0, j = 0;
var seasonMean = 0;
while (i<seasonCount) {
seasonMean = deseasonalizedTimeSerie[i].y;
j = 0;
while (j<seasonLength) {
timeSerie[i*seasonLength+j].y = (seasonMean - timeSerie[i*seasonLength+j].y) + seasonMean;
j++;
}
i++;
}
updateTimeline(WITH_TRANSITION);
updateDots(WITH_TRANSITION);
updateDeseasonalizedDots(WITH_TRANSITION);
updateTrend(WITH_TRANSITION);
updateDeseasonalizedTrend(WITH_TRANSITION);
}
function permuteSeasonality() {
//objective: in each season, n-th value becomes the (n-1)-th value, and the first one becomes the last one
d3.transition()
.duration(duration/2)
.each("start", function() {
line.defined(function(d, i) { return (i%4)!=0; });
updateTimeline(WITHOUT_TRANSITION);
})
.transition()
.duration(duration)
.each("start", function() {
var i = 0;
while (i<timeSerie.length) {
if (timeSerie[i].x%seasonLength === 1) {
timeSerie[i].x += seasonLength-1;
} else {
timeSerie[i].x -= 1
}
i++;
}
updateTimeline(WITH_TRANSITION);
updateDots(WITH_TRANSITION);
updateDeseasonalizedDots(WITH_TRANSITION);
updateTrend(WITH_TRANSITION);
updateDeseasonalizedTrend(WITH_TRANSITION);
})
.transition()
.duration(duration/2)
.each("end", function() {
timeSerie.sort(function(d0, d1){ return d0.x-d1.x; });
line.defined(function(d) { return true; });
updateTimeline(WITHOUT_TRANSITION);
updateDots(WITHOUT_TRANSITION);
});
}
function updateDeseasonalizedTimeSerie() {
//for each season, the mean is computed
deseasonalizedTimeSerie = [];
var seasonLength = 4; //for a sake of simplicity, this parameter is static, but technics allow to find seasonality from raw time serie (eg. [fast] Fourier transformation)
var seasonCount = timeSerie.length/seasonLength;
var i = 0, j = 0;
var seasonCountSum = 0;
while (i<seasonCount) {
seasonCountSum = 0;
j = 0;
while (j<seasonLength) {
seasonCountSum += timeSerie[i*seasonLength+j].y;
j++;
}
deseasonalizedTimeSerie.push({x: (i+0.5)*seasonLength+0.5, y:seasonCountSum/seasonLength});
i++;
}
}
function updateTrend(withTransition) {
// The objective is to draw a line that is the closest line from each point
// (cf. https://en.wikipedia.org/wiki/Linear_regression)
// A simple regression line is of the form y=ax+b, where a is the trend of the time serie
// below code computes 'a' and 'b'
var serieLength = timeSerie.length;
var timeInterval = 1
var countSum = 0;
var orderCountSum = 0;
timeSerie.forEach(function(d){
countSum += d.y;
orderCountSum += (d.x)*(d.y);
});
var a = (12*orderCountSum - 6*(serieLength+1)*countSum)/(timeInterval*serieLength*(serieLength-1)*(serieLength+1));
var b = (2*(2*serieLength+1)*countSum - 6*orderCountSum)/(serieLength*(serieLength-1));
trendLine
.transition()
.duration(withTransition? duration : 0)
.attr("y1", y(b))
.attr("y2", y(a*serieLength+b));
}
function updateDeseasonalizedTrend(withTransition) {
var serieLength = timeSerie.length;
var timeInterval = 1
var countSum = 0;
var orderCountSum = 0;
deseasonalizedTimeSerie.forEach(function(d){
countSum += d.y;
orderCountSum += (d.x)*(d.y);
});
var a = (12*orderCountSum - 6*(serieLength+1)*countSum)/(timeInterval*serieLength*(serieLength-1)*(serieLength+1))*seasonLength;
var b = (2*(2*serieLength+1)*countSum - 6*orderCountSum)/(serieLength*(serieLength-1))*seasonLength;
deseasonalizedTrendLine
.transition()
.duration(withTransition? duration : 0)
.attr("y1", y(b))
.attr("y2", y(a*serieLength+b));
}
function dragStarted(d) {
d3.select(this).classed("dragging", true);
}
function dragged(d) {
d.y += y.invert(d3.event.dy)
updateTimeline(WITHOUT_TRANSITION);
updateDots(WITHOUT_TRANSITION);
updateTrend(WITHOUT_TRANSITION);
updateDeseasonalizedTimeSerie();
updateDeseasonalizedDots(WITHOUT_TRANSITION);
updateDeseasonalizedTrend(WITHOUT_TRANSITION);
}
function dragEnded(d) {
d3.select(this).classed("dragging", false);
}
</script>
x y
1 20
2 16
3 15
4 6
5 23
6 20
7 20
8 10
9 30
10 25
11 24
12 14
13 33
14 30
15 30
16 18
17 42
18 34
19 33
20 23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment