Finding your One Metric That Matters using SQL
Last time, we did a detailed walk-through on how to calculate retention from your user and event tables in SQL. Now we get to the real pay-off--ad-hoc segmentation of your customers based on retention.
Segmentation, in this context, is a grouping of your users in a way that helps you make business decisions. Here are a few examples:
You have a B2B product, and different sized companies need different features (security, LDAP authentication, user management, admin control, etc). You want to figure out how your customers and potential customers fall into different groups so that you can fine-tune your marketing copy for each kind of customer. (This is why most B2B companies talk about "solutions" rather than products--one product may be used different ways to solve problems for different kinds of customers.)
You have a freemium app and want to find out who your product qualified leads are. This means, you want to know who is most likely to convert to a highly engaged user. There is often an important tipping point, and knowing it helps you focus on getting people to that tipping point, and then upselling when they hit it. Twitter has honed in on 7 days per month--if you use Twitter that much, you are highly likely to stick around for many months. FriendFeed once found that if a user had 5 friends on the site, they'd become an active user. Finding the tipping point is a kind of segmentation that requires exploring your dataset for both the factors and the quantities that predict the user is getting a lot of value.
So lets find out how FriendFeed might have gotten that magic number. How can we find out what minimum number of friends you need before you stick?
Determining what metric leads to retention is difficult. Since most metrics go up with retention, it's easy to reverse the causality--maybe users just get more friends because they stick around longer. So I'm not going to show a foolproof analysis here, but we can use this problem as an interesting use case to try some cool SQL.
If we're right that hitting a certain friend threshold leads to high engagement and retention in a social product, then we should see an inflection point after users hit that threshold. High engagement and retention should lead to even higher friend counts. So users who drop out after a few weeks should have no more than X friends, and users who stay longer should have much more than X.
To try to spot this threshold number, we can generate a chart of retention versus friend count for our users.
Let's imagine our tables are the same as last time, but we now have a friend count field on the users table called 'friends':
To start, we need to figure out the retention for each user. We want to get the most recent login time, and subtract that from the sign up time.
SELECT users.id, -- Find most recent login time and calculate retention MAX(events.time) - users.sign_up_time as retention_time FROM users JOIN events ON users.id = events.user_id GROUP BY 1;
Pretty straightforward. In PostgreSQL, we can simply subtract two timestamps and we'll get an "interval". Here's what the result looks like:
id | retention_time ------+---------------- 1074 | 1 day 22:01:14 887 | 34 days 02:23:38 1209 | 14 days 13:33:20 1420 | 89 days 05:03:45 1532 | 87 days 01:16:32 1101 | 4 days 18:44:43 577 | 71 days 09:52:11 ...
This looks pretty nice. But we want to plot friend counts against retention_time. Most charting software won't handle the text description of an interval so well, so we need to get a numeric value into retention_time. In PostgreSQL, we can use the extract function to pull a particular field out of the interval. In this case, we want to look at weeks. PostgreSQL extract doesn't support weeks, but it does support days:
SELECT users.id, -- Find most recent login time and calculate retention in weeks floor(extract(days from MAX(events.time) - users.sign_up_time) / 7) as retention_weeks FROM users JOIN events ON users.id = events.user_id GROUP BY 1; id | retention_weeks ------+----------------- 1074 | 1 887 | 4 1209 | 2 1420 | 12 1532 | 12 1101 | 0 577 | 10 ...
In this report, we're looking at users by how long they were retained. But in the case of new users, we don't know how long they're going to stick around. In this query, a user who signed up one week ago will look the same as a user who signed up three months ago but only stayed one week. So we will cut out any user who signed up less than four weeks ago.
SELECT users.id, -- Find most recent login time and calculate retention in weeks floor(extract(days from MAX(events.time) - users.sign_up_time) / 7) as retention_weeks FROM users JOIN events ON users.id = events.user_id -- Ignore recent sign ups, we don't know if they'll abandon yet AND users.sign_up_time > now() - interval '4 weeks' GROUP BY 1;
Now we get to group by friend count. But we can't do it in this query, since we're already grouping by users.id to find the most recent login event. We need to use this query as a subquery, so we can aggregate it again:
SELECT -- Show friend counts by # of weeks retained retention.retention_weeks, avg(users.friends) as avg_friends FROM users JOIN ( SELECT users.id, -- Find most recent login time and calculate retention in weeks floor(extract(days from MAX(events.time) - users.sign_up_time) / 7) as retention_weeks FROM users JOIN events ON users.id = events.user_id -- Ignore recent sign ups, we don't know if they'll abandon yet AND users.sign_up_time > now() - interval '4 weeks' GROUP BY 1 ) retention ON users.id = retention.id GROUP BY 1 ORDER BY 1;
And here's what the result might look like. Remember, this shows "For users who use the product for X weeks and then abandon, the average number of friends is Y."
retention_weeks | avg_friends -----------------+--------------------- 0 | 1.02144631384730000 1 | 2.48581334349008808 2 | 6.74929485869492110 3 | 3.86893085019385901 4 | 10.1238238084029199 5 | 11.4918409583109385 6 | 45.0391858292459861 ...
As we expected (because I'm using fake data), there's a disconnect between people who stick around for only 3 weeks and those who stick around for 4 or longer. However, there's one way we could refine this query. We're looking at the average number of friends for a group, but averages are subject to skew in the case of outliers. Also, we probably are more interested in the top of the range than the middle. One trick to remove the influence of outliers is to look at the 90th percentile of the data.
Percentiles are a great chance to use window functions. Window functions are a really cool part of SQL that as far as I can tell very few people know about. Here's how the PostgreSQL documentation describes them:
A window function performs a calculation across a set of table rows that are somehow related to the current row. This is comparable to the type of calculation that can be done with an aggregate function. But unlike regular aggregate functions, use of a window function does not cause rows to become grouped into a single output row â the rows retain their separate identities. Behind the scenes, the window function is able to access more than just the current row of the query result.
That's a little confusing, so here's a simple example. Take a table of grades with the columns: year, student, gpa. We could use a window function to add a 5th column which shows each student's rank in their year. Let's say the table looks like:
SELECT * FROM gpas; year | student | gpa ---------------------- 2012 | Alice | 4.0 2012 | Bob | 3.2 2012 | Charlie | 4.0 2012 | Dylan | 2.6 2011 | Elaine | 3.2 2011 | Frank | 3.9 2011 | Greg | 3.3 2011 | Harriet | 3.7
Then we can find the student rank using a window function. Window functions require an "OVER" clause which defines the window frame, the set of rows that are the context for the function, and an ordering for those rows. PostgreSQL has a window function called rank() which gives you the 1-based rank for the row based on the window frame and ordering.
SELECT year, student, gpa, rank() OVER (PARTITION BY year ORDER BY gpa DESC) AS class_rank FROM gpas; year | student | gpa | class_rank ----------------------------------- 2012 | Alice | 4.0 | 1 2012 | Bob | 3.2 | 3 2012 | Charlie | 4.0 | 1 2012 | Dylan | 2.6 | 4 2011 | Elaine | 3.2 | 4 2011 | Frank | 3.9 | 1 2011 | Greg | 3.3 | 3 2011 | Harriet | 3.7 | 2
Cool. So here's how it works for us. We can use a window function to add a new column to our subquery table showing what percentile that user is in based on the user's friend count. And we can calculate the percentile among users who were retained for the same number of weeks. Here's the new subquery:
SELECT users.id, -- Find most recent login time and calculate retention in weeks extract(days from MAX(events.time) - users.sign_up_time) / 7 as retention_weeks, users.friends, ntile(100) OVER (PARTITION BY extract(days from MAX(events.time) - users.sign_up_time) / 7 ORDER BY users.friends ASC) as percentile FROM users JOIN events ON users.id = events.user_id GROUP BY 1
And the result would look like:
id | retention_weeks | friends | percentile ------+---------------------------------------- 1074 | 1 | 0 | 3 887 | 4 | 20 | 72 1209 | 2 | 3 | 88 1420 | 12 | 120 | 70 1532 | 12 | 123 | 70 1101 | 0 | 0 | 20 577 | 10 | 4 | 2 ...
NTILE(n) is a PostgreSQL window function that splits the window frame into n even buckets and assigns a bucket number. Now we can find the 90th percentile by just picking the rows with percentile = 90. However, since there is more than one row in each percentile, we'll group by retention weeks and pick the maximum friend number in the 90th percentile bucket for each retention week.
SELECT retention.retention_weeks, max(retention.friends) as friends_90_percentile FROM ( SELECT users.id, users.friends, ntile(100) OVER (PARTITION BY extract(days from MAX(events.time) - users.sign_up_time) / 7 ORDER BY users.friends ASC) as percentile, -- Find most recent login time and calculate retention in weeks extract(days from MAX(events.time) - users.sign_up_time) / 7 as retention_weeks FROM users JOIN events ON users.id = events.user_id GROUP BY 1 ) retention WHERE percentile = 90 GROUP BY retention_weeks;
And we get our final result:
retention_weeks | avg_friends -----------------+--------------------- 0 | 1 1 | 3 2 | 3 3 | 5 4 | 12 5 | 17 6 | 70 ...
And it looks like our number is somewhere between 5 and 12.
Again, I don't think this analysis is easy, and I didn't have a social networking data set to work on. You'd probably want to look at your numbers a few more ways. It would be illustrative to look at a simple histogram of friend counts--you might see a dip in the distribution around your activation number, suggesting that if people get at least 6 friends, they'll stick around and then most likely accumulate a few more (and not stay at the 6 number for long).
Also, while I was excited to show off window functions. It's probably better to just plot the individual user data as a scatterplot. Then you can visualize the distribution of friend counts within each retention time frame. However, if your user base is large, this would be a lot of data, and the aggregation is a good first step that lets your database do all the work.
I hope this introduced you to some new ways to perform analysis in SQL. I first used SQL a decade ago, but I continue to learn new things about it all the time. If you need help with any of the concepts here, feel free to email me at [email protected].
You should follow us on Twitter.