# Line graph has too many lines, is there a better solution?

I'm trying to graph the number of actions by users (in this case, "likes") over time.

So I have "Number of actions" as my y-axis, my x-axis is time (weeks), and each line represents one user.

My problem is that I want to look at this data for a set of about 100 users. A line graph quickly becomes a jumbled mess with 100 lines. Is there a better type of graph I can use to display this information? Or should I look at being able to toggle individual lines on/off?

I'd like to see all the data at once, but being able to discern the number of actions with high precision isn't terribly important.

# Why I'm doing this

For a subset of my users (top users), I want to find out which ones may not have liked a new version of the application that was rolled out on a certain date. I'm looking for significant drops in the number of actions by individual users.

• Have you considered making the lines semi-transparent by changing the alpha that's being used to plot them? – Fomite Dec 20 '12 at 22:26
• @EpiGrad Reasonable suggestion but that wouldn't really make it any easier to see what I'm looking for. – regulatethis Dec 20 '12 at 23:42
• @regulatethis I would suggest a "small multiple" approach using the facet_wrap function of ggplot2 to create a block of 4 x 5 charts (4 rows, 5 columns - adjust depending on the desired aspect ratio) with ~5 users per chart. That should be clear enough and you could scale it up to about 10 users per chart, giving room for 200 with a 4x5 plot or 360 with a 6x6 plot. – SlowLearner Dec 21 '12 at 2:47

I would like to suggest a (standard) preliminary analysis to remove the principal effects of (a) variation among users, (b) the typical response among all users to the change, and (c) typical variation from one time period to the next.

A simple (but by no means the best) way to do this is to perform a few iterations of "median polish" on the data to sweep out user medians and time period medians, then smooth the residuals over time. Identify the smooths that change a lot: they are the users you want to emphasize in the graphic.

Because these are count data, it's a good idea to re-express them using a square root.

As an example of what can result, here is a simulated 60-week dataset of 240 users who typically undertake 10 to 20 actions per week. A change in all users occurred after week 40. Three of these were "told" to respond negatively to the change. The left plot shows the raw data: counts of action by user (with users distinguished by color) over time. As asserted in the question, it's a mess. The right plot shows the results of this EDA--in the same colors as before--with the unusually responsive users automatically identified and highlighted. The identification--although it is somewhat ad hoc--is complete and correct (in this example).

Here is the R code that produced these data and carried out the analysis. It could be improved in several ways, including

• Using a full median polish to find the residuals, rather than just one iteration.

• Smoothing the residuals separately before and after the change point.

• Perhaps using a more sophisticated outlier detection algorithm. The current one merely flags all users whose range of residuals is more than twice the median range. Albeit simple, it is robust and appears to work well. (A user-settable value, threshold, can be adjusted to make this identification more or less stringent.)

Testing nevertheless suggests this solution works well for a wide range of user counts, 12 - 240 or more.

n.users <- 240        # Number of users (here limited to 657, the number of colors)
n.periods <- 60       # Number of time periods
i.break <- 40         # Period after which change occurs
n.outliers <- 3       # Number of greatly changed users
window <- 1/5         # Temporal smoothing window, fraction of total period
response.all <- 1.1   # Overall response to the change
threshold <- 2        # Outlier detection threshold

# Create a simulated dataset
set.seed(17)
base <- exp(rnorm(n.users, log(10), 1/2))
response <- c(rbeta(n.users - n.outliers, 9, 1),
rbeta(n.outliers, 5, 45)) * response.all
actual <- cbind(base %o% rep(1, i.break),
base * response %o% rep(response.all, n.periods-i.break))
observed <- matrix(rpois(n.users * n.periods, actual), nrow=n.users)

# ---------------------------- The analysis begins here ----------------------------#
# Plot the raw data as lines
set.seed(17)
colors = sample(colors(), n.users) # (Use a different method when n.users > 657)
par(mfrow=c(1,2))
plot(c(1,n.periods), c(min(observed), max(observed)), type="n",
xlab="Time period", ylab="Number of actions", main="Raw data")
i <- 0
apply(observed, 1, function(a) {i <<- i+1; lines(a, col=colors[i])})
abline(v = i.break, col="Gray")  # Mark the last period before a change

# Analyze the data by time period and user by sweeping out medians and smoothing
x <- sqrt(observed + 1/6)                        # Re-express the counts
mean.per.period <- apply(x, 2, median)
residuals <- sweep(x, 2, mean.per.period)
mean.per.user <- apply(residuals, 1, median)
residuals <- sweep(residuals, 1, mean.per.user)

smooth <- apply(residuals, 1, lowess, f=window)  # Smooth the residuals
smooth.y <- sapply(smooth, function(s) s$y) # Extract the smoothed values ends <- ceiling(window * n.periods / 4) # Prepare to drop near-end values range <- apply(smooth.y[-(1:ends), ], 2, function(x) max(x) - min(x)) # Mark the apparent outlying users thick <- rep(1, n.users) thick[outliers <- which(range >= threshold * median(range))] <- 3 type <- ifelse(thick==1, 3, 1) cat(outliers) # Print the outlier identifiers (ideally, the last n.outliers) # Plot the residuals plot(c(1,n.periods), c(min(smooth.y), max(smooth.y)), type="n", xlab="Time period", ylab="Smoothed residual root", main="Residuals") i <- 0 tmp <- lapply(smooth, function(a) {i <<- i+1; lines(a, lwd=thick[i], lty=type[i], col=colors[i])}) abline(v = i.break, col="Gray") • For more than 100-200 users, I would increase threshold to about$2.5\$ to avoid false positives. E.g., try the code with n.users <- 500, n.outliers <- 100 (that's a large proportion!), and threshold <- 2.5. – whuber Dec 20 '12 at 23:52

Generally I find more than two or three lines on a single facet of a plot starts to be hard to read (although I still do it all the time). So this is an interesting example of what to do when you have something that conceptually could be a 100 facet plot. One possible way is to draw all 100 facets but instead of trying to get them all on the page at once, looking at them one at a time in an animation.

We've actually used this technique at my work - we originally made the animation showing 60 different line plots as background for an event (the launch of a new data series), then found that doing so we actually picked up some features of the data that hadn't been visible in faceted plots with 15 or 30 facets per page.

So here is an alternative way of presenting the raw data, before you start removing the user and typical time effects as recommended by @whuber. This is presented just as an additional alternative to his presentation of the raw data - I fully recommend that you then proceed with analysis along lines such as those he suggests.

One way of getting around this problem is to produce the 100 (or 240 in @whuber's example) time series plots separately and knit them together into an animation. The code below will produce 240 separate images of this sort and then you can use free movie making software to turn that into a movie. Unfortunately the only way I could do this and keep acceptable quality was a 9MB file, but if you don't need to send it across the internet that may not be a problem and anyway I'm sure there's ways around that with a bit more animation savvy. The animation package in R could be useful here (lets you do it all in a call from R) but I've kept it simple for this illustration.

I have made the animation such that it draws each line in heavy black then leaves a pale semi-transparent green shadow behind so the eye gets a gradual picture of the accumulating data. There are both risks and opportunities in this - the order that the lines are added will leave a different impression, so you should consider making it meaningful in some way.

Here are some of the stills from the movie, which uses the same data that @whuber generated:

# ---------------------------- Data generation - by @whuber ----------------------------#

n.users <- 240        # Number of users (here limited to 657, the number of colors)
n.periods <- 60       # Number of time periods
i.break <- 40         # Period after which change occurs
n.outliers <- 3       # Number of greatly changed users
window <- 1/5         # Temporal smoothing window, fraction of total period
response.all <- 1.1   # Overall response to the change
threshold <- 2        # Outlier detection threshold

# Create a simulated dataset
set.seed(17)
base <- exp(rnorm(n.users, log(10), 1/2))
response <- c(rbeta(n.users - n.outliers, 9, 1),
rbeta(n.outliers, 5, 45)) * response.all
actual <- cbind(base %o% rep(1, i.break),
base * response %o% rep(response.all, n.periods-i.break))
observed <- matrix(rpois(n.users * n.periods, actual), nrow=n.users)

# ---------------------------- The analysis begins here ----------------------------#

# Alternative presentation of original data
#
setwd("eg animation")

for (i in 1:n.users){
png(paste("line plot", i, ".png"),600,600,res=60)
plot(c(1,n.periods), c(min(observed), max(observed)),
xlab="Time period", ylab="Number of actions",
main="Raw data", bty="l", type="n")
if(i>1){apply(observed[1:i,], 1, function(a) {lines(a, col=rgb(0,100,0,50,maxColorValue=255))})}
lines(observed[i,], col="black", lwd=2)
abline(v = i.break, col="Gray")  # Mark the last period before a change
text(1,60,i)
dev.off()
}

##
# Then proceed to further analysis eg as set out by @whuber
• +1, this is a nice idea. You can also initiate a new device window using windows() or quartz(), and then nest your for() loop inside it. NB, you'll need to put a Sys.sleep(1) at the bottom of your loop so that you can actually see the iterations. Of course, this strategy doesn't actually save a movie file--you just have to re-run it every time you want to watch it again. – gung - Reinstate Monica Dec 21 '12 at 20:54
• +1 Very nice idea--I will try this the next chance I get. (GTW, Mathematica, for instance, makes short work of creating and saving such animations.) – whuber Dec 21 '12 at 21:06
• Amazing idea - an animation along these lines (or the code and data to generate) would make a very sexy online appendix to a publication. – N Brouwer Dec 27 '12 at 19:50

One of the easiest things to is a boxplot. You can immediately see how your sample medians move and what days have the most outliers.

day <- rep(1:10, 100)
likes <- rpois(1000, 10)
d <- data.frame(day, likes)
library(ggplot2)
qplot(x=day, y=likes, data=d, geom="boxplot", group=day)

For individual analysis I suggest taking a small random sample from your data and analysing separate time series.

• Interesting solution, but what I really want to be able to see how is the "change" on a per user basis. I want to see the fluctuations in activity for individual users. That's why I chose a line initially, but the visualization is just too cluttered now. – regulatethis Dec 20 '12 at 21:46
• well, it really depends on what patterns you want to be able to see in your data, perhaps if you could tell us what are you trying to find out, we could come up with solution. – jem77bfp Dec 20 '12 at 21:50
• For a subset of my users (top users), I want to find out which ones may not have liked a new version of the application that was rolled out on a certain date. I'm looking for significant drops in the number of actions by individual users. – regulatethis Dec 20 '12 at 22:01
• Welcome to the site @jem77bfp. he did say he wanted to see all the data. But it would be nice to have more details, I agree. – Peter Flom - Reinstate Monica Dec 20 '12 at 22:01
• +1 - instead of visualizing the box plots though it can be useful to connect the summary statistics in line graphs. See this answer of mine for an example and discussion below. – Andy W Dec 20 '12 at 22:16

Sure. First, sort by average number of actions. Then make (say) 4 graphs, each with 25 lines, one for each quartile. That means you can shrink the y-axes (but make the y axis label clear). And with 25 lines, you can vary them by line type and color and perhaps plotting symbol and get some clarity

Then stack the graphs vertically with a single time axis.

This would be pretty easy in R or SAS (at least if you have v. 9 of SAS).

• +1 - I would suggest even fewer lines per small multiple though! See my related blog post on the subject and an example. Sorting is also a great idea, and other potential ones could include value at baseline or follow-up, or measures of change (such as positive or negative slope, percent change, etc). – Andy W Dec 20 '12 at 22:14
• Nice! What is the community blog? How does one access or write for it? – Peter Flom - Reinstate Monica Dec 21 '12 at 11:08
• feel free to stop by the Skewed Distribution chat room for details on how to join the blog. We are always open for more contributions from community members. – Andy W Dec 21 '12 at 12:11

I find that when your running out if options regarding type if graph and graph settings introduction of time through animation is the best way to display because it gives you an extra dimension to work with and allows you to display more information in an easy to follow way. Your primary focus must be on the end user experience.

If you're most interested in the change for individual users, maybe this is a good situation for a collection of Sparklines (like this example from The Pudding):

These are pretty detailed, but you could show a lot more charts at once by removing axis labels and units.

Many data tools have them built in (Microsoft Excel has sparklines), but I'm guessing you'd want to pull in a package to build them in R.