Showing posts with label Percentile. Show all posts
Showing posts with label Percentile. Show all posts

Friday, September 11, 2009

Fun With Percentiles, Part 2

In Part 1, we talked about how to calculate a value V for a given percentile p in a given set of numbers. We looked at the mathematical formula that Excel uses for its PERCENTILE() function and then put together some T-SQL scripts that used those formulas.

In this blog entry, we are going to put together the inverse of the PERCENTILE() function. Excel calls this the PERCENTRANK() function. In other words, given a value V we are going to calculate its percentile p.

For those of you who can’t get enough of algebra and logic, you can look at the mathematical formula presented in the last blog entry and see how the following formula for PERCENTRANK() is derived.

Given a set of N ordered values, {v[1], v[2], ... , v[N]}, the percentile p of the value V is calculated as follows:

Find the first value v[k] that is exactly equal to V. If found then set d equal to 0.00. Otherwise find the two adjacent values v[k] and v[k+1] such that v[k] < V < v[k+1], and then calculate d as follows:

d = (V – v[k]) / (v[k+1] – v[k])

Using these values of k and d, the percentile can be calculated:

p = (k + d – 1) / (N – 1)

As an example, let’s try the value of 11.75 and see what percentile that represents in the set of {1,5,9,20}. Since there are 4 values in the set, N is equal to 4. Our value V of 11.75 is between the values of 9 and 20, which are the 3rd and 4th entries in the ordered set, so k is 3 and k+1 is 4. And so…

d = (11.75 – v[3]) / (v[4] – v[3]) = (11.75 – 9) / (20 – 9) = 0.25

And therefore…

p = (3 + 0.25 – 1) / (4 – 1) = 0.75

So 11.75 represents the 75th percentile of {1,5,9,20}.

Before attacking this in T-SQL, let’s again build a temporary table called #Set which contains our 4 values:

if object_id('tempdb..#Set') is not null drop table #Set
go
create table #Set (SetValue int)
go
insert #Set select 1
union all select 5
union all select 9
union all select 20
The hardest part of this is to determine where the value V falls in the set of values. Let’s attack this problem by taking the original set and create a derived table that consists of the ordered ranges of those values. First a CTE called SequencedData orders the items in the set, then we JOIN that CTE with itself, in order to create ranges of adjacent values in the set:

;with SequencedData as
(
select SetValue
,SeqNo=row_number() over (order by SetValue)
from #Set
)
select FromValue=Cur.SetValue
,ToValue=Nxt.SetValue
,FromSeqNo=Cur.SeqNo
,ToSeqNo=Nxt.SeqNo
from SequencedData Cur
full join SequencedData Nxt on Cur.SeqNo=Nxt.SeqNo-1
/*
FromValue ToValue FromSeqNo ToSeqNo
---------- -------- ---------- --------
NULL 1 NULL 1
1 5 1 2
5 9 2 3
9 20 3 4
20 NULL 4 NULL
*/
So we now know that the values 1 thru 5 represent the 1st and 2nd items in the set, and values 5 thru 9 represent the 2nd and 3rd items, etc. Now that we have that information, we can find out which range contains our value V:

declare @V decimal(7,2)
set @V=11.75
;with SequencedData as
(
select SetValue
,SeqNo=row_number() over (order by SetValue)
from #Set
)
,
DataRanges as
(
select FromValue=Cur.SetValue
,ToValue=Nxt.SetValue
,FromSeqNo=Cur.SeqNo
,ToSeqNo=Nxt.SeqNo
from SequencedData Cur
full join SequencedData Nxt on Cur.SeqNo=Nxt.SeqNo-1
)
select FromValue
,ToValue
,FromSeqNo
,ToSeqNo
from DataRanges
where (FromValue is null or @V>FromValue)
and (@V<=ToValue or ToValue is null)
/*
FromValue ToValue FromSeqNo ToSeqNo
---------- -------- ---------- --------
9 20 3 4
*/
So our value of 11.75 lies between 9 and 20, which are the 3rd and 4th items.

Notice that the WHERE clause specifically asked for @V to be less than OR EQUAL to ToValue. This is the way we can see if our value V is actually IN the set of values. If @V is exactly equal to ToValue, then we can set k equal to ToSeqNo and set d equal to 0.00. If they are not equal, then @V falls between FromValue and ToValue and so k is equal to FromSeqNo, and k+1 is equal to ToSeqNo, and we can calculate d as indicated by our mathematical formula. Then, once we have k and d we can calculate our percentile p:

declare @V decimal(7,2)
set @V=11.75
;with SequencedData as
(
select SetValue
,SeqNo=row_number() over (order by SetValue)
from #Set
)
,
DataRanges as
(
select FromValue=Cur.SetValue
,ToValue=Nxt.SetValue
,FromSeqNo=Cur.SeqNo
,ToSeqNo=Nxt.SeqNo
from SequencedData Cur
full join SequencedData Nxt on Cur.SeqNo=Nxt.SeqNo-1
)
,
TargetRange as
(
select FromValue
,ToValue
,FromSeqNo
,ToSeqNo
from DataRanges
where (FromValue is null or @V>FromValue)
and (@V<=ToValue or ToValue is null)
)
select p
from TargetRange
cross apply (select N=count(*) from #Set) F1
cross apply (select k=case
when ToValue=@V
then ToSeqNo
else FromSeqNo
end
,d=case
when ToValue=@V
then 0.00
else 1.0*(@V-FromValue)/(ToValue-FromValue)
end) F2
cross apply (select p=(k+d-1)/(N-1)) F3
/*
p
--------------------
0.75000000000000000
*/
Well, that looks great. However, once again, the query plan is not so great. JOINing the SequencedData CTE to itself to get the ranges forces SQL to sort the table 2 times.

Consider another approach… Our problem is that we’re trying to look for the values v[k] and v[k+1] such that v[k] < V <= v[k+1]. But if you sit and think about it, v[k] is simply the maximum value in the entire set that is less than V and v[k+1] is simply the minimum value in the entire set that is greater than or equal to V. If we find that v[k+1] is exactly equal to V, then we can set k equal to the quantity of entries in the set that are less than V and add 1 to that. Otherwise, if we find that v[k+1] is greater than V, then we can set k equal to the quantity of entries less than V. We have all of our information we need right there without having to sort anything. We can just use aggregate functions.

So here’s a new query that addresses all of the above. The first CTE calculates N, v[k], v[k+1], and it also calculates the number of entries that are less than V and the number that are equal to V. Then the main query calculates k and d based on whether V is in the set of values or not, and then finally calculates the percentile p:

declare @V decimal(7,2)
set @V=11.75
;with ValuesAndCounts as
(
select N=count(*)
,[v(k)]=max(case when SetValue<@V then SetValue end)
,[v(k+1)]=min(case when @V<=SetValue then SetValue end)
,QtyLessThan=sum(case when SetValue<@V then 1 else 0 end)
,QtyEqualTo=sum(case when SetValue=@V then 1 else 0 end)
from #Set
)
select p
from ValuesAndCounts
cross apply (select k=case
when QtyEqualTo>0
then QtyLessThan+1
else QtyLessThan
end
,d=case
when QtyEqualTo>0
then 0.00
else 1.0*(@V-[v(k)])/([v(k+1)]-[v(k)])
end) F2
cross apply (select p=(k+d-1)/(N-1)) F3
/*
p
---------------------------
0.750000000000000000000000
*/
I ran a test on a set of 100,000 values, comparing this query to the one with the self-JOINing ROW_NUMBER() CTE approach, and this query performed much better. The CPU decreased by 67%, the Reads decreased by 67%, and the Execution Time decreased by 75%.

Now that we have the basic query approach in place, we can apply this concept to the AdventureWorks database. In my last blog entry, we calculated both the median (50th percentile) and the mean (the T-SQL AVG()) of Total Sales Dollars By Customer (only looking at Customers who were Stores rather than Individuals). We found that the median was $62,257 and the mean was $170,498.

So what percentile does that mean value of $170,498 represent?

declare @V money
set
@V=170498
;with BaseData as
(
select c.CustomerID
,CustDollars=sum(h.TotalDue)
from Sales.SalesOrderHeader h
join Sales.Customer c on h.CustomerID=c.CustomerID
where c.CustomerType='S'
group by c.CustomerID
)
,
ValuesAndCounts as
(
select N=count(*)
,[v(k)]=max(case when CustDollars<@V then CustDollars end)
,[v(k+1)]=min(case when @V<=CustDollars then CustDollars end)
,QtyLessThan=sum(case when CustDollars<@V then 1 else 0 end)
,QtyEqualTo=sum(case when CustDollars=@V then 1 else 0 end)
from BaseData
)
select p
from ValuesAndCounts
cross apply (select k=case
when QtyEqualTo>0
then QtyLessThan+1
else QtyLessThan
end
,d=case
when QtyEqualTo>0
then 0.00
else 1.0*(@V-[v(k)])/([v(k+1)]-[v(k)])
end) F2
cross apply (select p=(k+d-1)/(N-1)) F3
/*
p
--------------------
0.66802891020021456
*/
So $170,498 is between the 66th and 67th percentile… and it represents a value that is higher than 66.8% of all Customers.

Everything looks great… All is well with the world… we have successfully duplicated the Excel PERCENTRANK() function.

But…

Don't sit back and relax yet.

I personally have a problem with the way that Excel calculates it… specifically I don’t like the way it handles (or doesn’t handle) the value V being in the set multiple times. Let me give you an example. Let’s create a table of scores for the SAT Test. Only 100 people took the test… 5 of them got the lowest score you can possibly get on the SAT (200) and 5 of them got the highest possible score (2400). The other remaining 90 people got a very respectable score of 1900:

if object_id('tempdb..#SAT') is not null drop table #SAT
go
create table #SAT (Score int)
go
insert #SAT
select 200
go 5
--SpongeBob,Curly,Sluggo,Gilligan,Shemp
insert #SAT
select 2400
go 5
--Plato,Galileo,Leonardo,Wolfgang,Albert
insert #SAT
select 1900
go 90
--The rest of us
Now we have a set of 100 values: {200, 200, 200, 200, 200, 1900, 1900, …, 1900, 1900, 2400, 2400, 2400, 2400, 2400}. What is the 80th percentile of this set? It’s 1900. What is the 60th percentile? It’s also 1900. And the 20th and 40th percentile are 1900. In fact, EVERY percentile from 6 to 94 has a value of 1900.

So what does Excel (and our new query) think the percentile represented by 1900 is?

declare @V int
set
@V=1900
;with ValuesAndCounts as
(
select N=count(*)
,[v(k)]=max(case when Score<@V then Score end)
,[v(k+1)]=min(case when @V<=Score then Score end)
,QtyLessThan=sum(case when Score<@V then 1 else 0 end)
,QtyEqualTo=sum(case when Score=@V then 1 else 0 end)
from #SAT
)
select p
from ValuesAndCounts
cross apply (select k=case
when QtyEqualTo>0
then QtyLessThan+1
else QtyLessThan
end
,d=case
when QtyEqualTo>0
then 0.00
else 1.0*(@V-[v(k)])/([v(k+1)]-[v(k)])
end) F2
cross apply (select p=(k+d-1)/(N-1)) F3
/*
p
--------------------------
0.05050505050505050505050
*/
It thinks that 1900 is just a smidgen above the 5th percentile. I guess you could argue that this is correct, since 1900 was only better than 5 scores out of 100. But it was better than OR EQUAL TO 95 scores out of 100. Shouldn’t that be taken into account somehow? It seems like there should be a compromise of some sort, considering that 1900 represented every percentile from 6 to 94.

By making a small adjustment to the query (where d is calculated), we can come to this compromise:

declare @V int
set
@V=1900
;with ValuesAndCounts as
(
select N=count(*)
,[v(k)]=max(case when Score<@V then Score end)
,[v(k+1)]=min(case when @V<=Score then Score end)
,QtyLessThan=sum(case when Score<@V then 1 else 0 end)
,QtyEqualTo=sum(case when Score=@V then 1 else 0 end)
from #SAT
)
select p
from ValuesAndCounts
cross apply (select k=case
when QtyEqualTo>0
then QtyLessThan+1
else QtyLessThan
end
,d=case
when QtyEqualTo>0
then (QtyEqualTo-1)/2.0 --instead of 0.00
else 1.0*(@V-[v(k)])/([v(k+1)]-[v(k)])
end) F2
cross apply (select p=(k+d-1)/(N-1)) F3
/*
p
--------------------------
0.50000000000000000000000
*/
That’s a better answer in my opinion. Those 90 people who took the test and got a 1900 all fell as a group in the middle of the set of all scores, so we can report back to them that they were in the 50th percentile.

I hope you’ve enjoyed the exercise of implementing (and improving) Excel’s PERCENTILE() and PERCENTRANK() functions in T-SQL. These statistical concepts can be very useful in analyzing your data. I also urge you to explore the web, as there are other alternate formulas out there for calculating percentiles and percentile ranks. Most use the same core approach, but differ in how they perform interpolations and minor calculations. You'll also come to appreciate why there is no agreed-upon definition of how to calculate percentiles.

Sunday, September 6, 2009

Fun With Percentiles, Part 1

Let’s say you have 100,000 rows in a table that has a Dollars column. If you perform the following…

select *, GroupValue=ntile(100) over (order by Dollars)
from MyTable
… it will split the rows into 100 equal sized subsets (of 1000 rows each) by sorting the rows by Dollars and then assigning a value of 1, 2, 3, … , 100 to the column GroupValue.

But despite the enticing name of the function, NTILE() doesn’t do anything for you as far as calculating percentile values. Percentiles are the 99 data values that mark the boundaries between those 100 subsets. For example, the 80th percentile marks the boundary where 80% of the values lie at or below it and 20% of the values lie at or above it. Perhaps the most “famous” of the percentile values is the 50th percentile, which you may know as the median.

There doesn’t seem to be any standard definition of percentile. Many references will say that the nth percentile has n% of the values at or below it (as I did above), and many will say that it has n% of the values just below it. But that’s kind of nitpicky stuff.

Unfortunately, though, there doesn’t seem to be a standard calculation of a percentile either. For example, let’s say you have a set of 3 numbers {1,5,9}. The 50th percentile is 5, because it’s right there in the middle. But what if you have a set of 4 numbers {1,5,9,20}? There is no number in the middle here, so you have to take the average of the middle two numbers. Therefore, the 50th percentile of this set is (5 + 9) / 2 = 7. That seems straightforward.

But now what’s the value of the 75th percentile? Is it the average of the 3rd and 4th numbers (which would be 14.5)? Or is it something else? This is where the definitions differ. For example, Microsoft Excel would calculate the 75th percentile of that set of 4 numbers to be 11.75.

For the purposes of this exercise, in order to be consistent across Microsoft products, I’ll use the definition that Microsoft Excel uses.

Excel has a PERCENTILE() function that accepts 2 parameters: an array, and a percentile value which must be in the range of 0 to 1, with 0.75 representing the 75th percentile, for example. Note that you can pass any real number between 0 and 1 to the function… If, for some bizarre reason, you wanted to find out the 56.27th percentile, you can pass 0.5627. The illustration below shows the PERCENTILE() function being used in Excel on our familiar set of 4 numbers.



How does Excel calculate this function? Okay, here goes…

Given a set of N ordered values, {v[1], v[2], ... , v[N]}, the value V of the percentile p (expressed as a decimal) is calculated as follows:

First, calculate an intermediate result:

I = p(N – 1) + 1

That intermediate result is split into an integer component, k, and a decimal component, d, such that k + d = I

Now the value of the percentile can be calculated like so:

V = v[k] + d(v[k+1] – v[k])

Whew! That’s a lot of variables, but it’s easier when you see an example in action. Let's try the 75th percentile of our set of {1,5,9,20}. In this case p is 0.75 and N is 4. So…

I = 0.75(4 – 1) + 1 = 3.25

That splits up into k=3 and d=0.25. So our final answer is:

V = v[3] + 0.25(v[4] – v[3]) = 9 + 0.25(20 – 9) = 11.75

So, let’s translate that whole thing into T-SQL, shall we?

First we’ll create a temporary table called #Set, which will contain our set of 4 values:

if object_id('tempdb..#Set') is not null drop table #Set
go
create table #Set (SetValue int)
go
insert #Set select 1
union all select 5
union all select 9
union all select 20
Calculating the intermediate result is easy. Our N value is the number of rows in our table, and the intermediate result, I, is calculated from that, and then the k and d values are calculated from that. (If you’re confused about the CROSS APPLYs, please check out my blog entry Cool CROSS APPLY Tricks, Part 2).

declare @p decimal(8,7)
set @p=0.75
select N,I,k,d
from (select N=count(*) from #Set) F1
cross apply (select I=@p*(N-1)+1) F2
cross apply (select k=floor(I)
,d=I-floor(I)) F3
/*
N I k d
--- ---------- --- ----------
4 3.2500000 3 0.2500000
*/
Now that we have a value for k, we can get the kth and (k+1)th entries out of our #Set table. We will use the ROW_NUMBER() function to order the values and assign a SeqNo to each value in order to acquire those 2 specific entries (i.e. the ones where SeqNo is equal to k and k+1). Once we get those two entries, we calculate our final result, V.

declare @p decimal(8,7)
set @p=0.75
;with SequencedData as
(
select SetValue
,SeqNo=row_number() over (order by SetValue)
from #Set
)
select V
from (select N=count(*) from #Set) F1
cross apply (select I=@p*(N-1)+1) F2
cross apply (select k=floor(I)
,d=I-floor(I)) F3
cross apply (select [v(k)]=(select SetValue
from SequencedData
where SeqNo=k)
,[v(k+1)]=(select SetValue
from SequencedData
where SeqNo=k+1)) F4
cross apply (select V=[v(k)]+d*([v(k+1)]-[v(k)])) F5
/*
V
----------
11.750000
*/
This works fine, and it all flows intuitively, but the way that we acquire v[k] and v[k+1] is not ideal. The query plan shows 2 Table Scans and 2 Sorts, because we’re doing two individual SELECTs from a CTE that involves ROW_NUMBER().

So let’s change that F4 CROSS APPLY so that it only does a single SELECT instead of 2 sub-query SELECTs:

declare @p decimal(8,7)
set @p=0.75
;with SequencedData as
(
select SetValue
,SeqNo=row_number() over (order by SetValue)
from #Set
)
select V
from (select N=count(*) from #Set) F1
cross apply (select I=@p*(N-1)+1) F2
cross apply (select k=floor(I)
,d=I-floor(I)) F3
cross apply (select [v(k)]=min(SetValue)
,[v(k+1)]=max(SetValue)
from SequencedData
where SeqNo between k and k+1) F4
cross apply (select V=[v(k)]+d*([v(k+1)]-[v(k)])) F5
/*
V
----------
11.750000
*/
That makes the query a bit more efficient. I ran a test, loading the #Set table with 100,000 entries, and the differences between those two queries was quite satisfying. The CPU decreased by 50%, the Reads decreased by 33%, and the Execution Time decreased by 44%.

Now let’s put this concept into practical use. Let’s get Total Sales Dollars by Customer for all Orders in the AdventureWorks database. We will only look at the Customers who are Stores (CustomerType=’S’) rather than Individuals (CustomerType=’I’). That will be our base data. Then we will find the 3 Quartiles (i.e. 25th, 50th, and 75th percentiles) and, just for kicks, we will also find the minimum and maximum Customer Sales Dollars by finding the 0th and 100th percentiles. In theory, there’s no such thing as the 0th and 100th percentile, but that is one side benefit to Excel's calculation method:

;with BaseData as
(
select c.CustomerID
,CustDollars=sum(h.TotalDue)
from Sales.SalesOrderHeader h
join Sales.Customer c on h.CustomerID=c.CustomerID
where c.CustomerType='S'
group by c.CustomerID
)
,
SequencedData as
(
select CustDollars
,SeqNo=row_number() over (order by CustDollars)
from BaseData
)
select Percentile=p
,[Value]=V
from (select N=count(*) from BaseData) F1
cross apply (select 0.00 union all
select 0.25 union all
select 0.50 union all
select 0.75 union all
select 1.00) Percentiles(p)
cross
apply (select I=p*(N-1)+1) F2
cross apply (select k=floor(I)
,d=I-floor(I)) F3
cross apply (select [v(k)]=min(CustDollars)
,[v(k+1)]=max(CustDollars)
from SequencedData
where SeqNo between k and k+1) F4
cross apply (select V=[v(k)]+d*([v(k+1)]-[v(k)])) F5
/*
Percentile Value
----------- ---------------
0.00 1.518300
0.25 10079.041700
0.50 62256.990300
0.75 237845.936350
1.00 1179857.465700
*/
Hmmm… half of our customers have Dollar Totals of about $62,000 or less. But then the 75th percentile leaps up to about $238,000, with the biggest customer generating about $1.18 million. Those are big leaps. Just out of curiosity, how does the median of $62,000 compare to the Average Sales Dollars Per Customer (also known as the mean)?

;with BaseData as
(
select c.CustomerID
,CustDollars=sum(h.TotalDue)
from Sales.SalesOrderHeader h
join Sales.Customer c on h.CustomerID=c.CustomerID
where c.CustomerType='S'
group by c.CustomerID
)
select AverageDollars=avg(CustDollars)
from BaseData
/*
AverageDollars
---------------
170498.0247
*/
So this tells us that the mean lies somewhere between the 50th and 75th percentile. The numbers indicate that our customers are not evenly spread across the spectrum and that the data is skewed to the lower side. In other words, a large number of our customers generate lower sales dollars. So which figure, $62,000 or $170,000, is more representative of the “average” customer? I'll leave that up to AdventureWorks management. An entire book could be written on the interpretation of the median and the mean. (But I'll bet that the CEO of AdventureWorks brags to his girlfriend using the higher number).

In my next blog entry, we’ll take a look at Excel’s PERCENTRANK() function. This is essentially the inverse of the PERCENTILE() function we looked at in this entry. This way we’ll take a look at that $170,498 that we calculated above and see what percentile that represents.