Categorisation¶
Exercise categorisation-annex.pdf · PDF
You open a chest in a game and a sword drops. Do you read its drop rate, "0.37%"? No. You look at the colour of its name. Common, Rare, Epic, Legendary. One word and you know whether to keep it.
Somebody decided where "Rare" ends and "Epic" begins. That person turned thousands of possible numbers into four labels, so that you could decide in half a second. On this page you do the same job for games: 199 different prices become five bands, and every game gets a tier for the size of its audience.
What you will learn
- Explain why we turn a number into a category, and what is lost by doing it
- Write a
CASE WHENwith several branches and predict which branch a given row takes - Explain why the order of the branches matters, and where a
NULLends up - Build
price_bandandowners_tier, and sort them in their logical order instead of alphabetically - Spot one category written in two spellings and merge them
- Avoid the trap: the bands are not "in the data", you drew them
From numbers to rarity tiers¶
Count how many different prices the table has:
SELECT COUNT(DISTINCT price) FROM games; -- 199
199 different prices. Draw one bar per price and you get 199 bars: unreadable. Nobody reads "drop rate 0.37%". Everybody reads "Epic".
A category is a label that puts a value into a group. Categorising is replacing many different values by a few labels.
A bin (or band) is one of those groups when the original value is a number: a range with a lower and an upper edge.
The analogy for the whole page is the rarity tier: a category is a number that has been made readable.
| The number | The category | |
|---|---|---|
| In the game | Drop rate 0.37% | Epic |
In games |
Price 9.99 | 5 to 15 |
| Good for | Calculating: sum, mean, sorting | Reading, grouping, comparing groups |
| How many values | 199 prices | 5 bands |
Careful: keep the number too
Categorising does not replace the price. It adds a label next to it, in a new column. You can always cut a number into new bands later. You can never get the number back out of a label.
CASE WHEN: the sorting rule¶
CASE WHENis a column that is calculated with conditions: for each row it gives the label of the first condition that is true.
The skeleton:
CASE WHEN condition_1 THEN 'label 1'
WHEN condition_2 THEN 'label 2'
ELSE 'label for the rest' END AS new_column
Each WHEN ... THEN ... line is a branch. Three rules decide everything:
| Rule | What it means |
|---|---|
| Top to bottom | Each row tries the branches in order and stops at the first one that is true. The order is part of the logic |
ELSE takes the rest |
Everything that matched nothing, including what you did not think of. Without ELSE, the rest becomes NULL |
NULL is never true |
NULL < 5 is not true and not false: it is unknown. A NULL falls through every comparison, down to ELSE |
flowchart TB
R["<b>One row</b><br>price = ?"] --> W1{"price IS NULL?"}
W1 -- "true" --> L1["'Free'"]
W1 -- "not true" --> W2{"price under 5?"}
W2 -- "true" --> L2["'Under 5'"]
W2 -- "not true" --> W3{"price under 15?"}
W3 -- "true" --> L3["'5 to 15'"]
W3 -- "not true" --> W4{"price under 30?"}
W4 -- "true" --> L4["'15 to 30'"]
W4 -- "not true" --> E["<b>ELSE</b><br>'30 or more'"]
The free game that looked expensive¶
Try the simplest possible rule on three games you know:
SELECT game, price,
CASE WHEN price < 5 THEN 'Under 5' ELSE '5 or more' END AS band
FROM games WHERE game IN ('Half-Life 2', 'Dota 2', 'Portal') ORDER BY game;
| game | price | band |
|---|---|---|
| Dota 2 | 5 or more | |
| Half-Life 2 | 9.99 | 5 or more |
| Portal | 9.99 | 5 or more |
Dota 2 is free. It got the band 5 or more. Its price is NULL, NULL < 5 is not true, so the row fell to ELSE. The game with the most owners in the whole table, and free, labelled as expensive. No error message.
Careful: ELSE does not mean the expensive ones
ELSE means everything else, and "everything else" includes the empty cells. If a NULL deserves its own label, name it first, on purpose, with WHEN column IS NULL.
Building price_band¶
Five bands, prices in US dollars. The NULL case goes first:
SELECT CASE WHEN price IS NULL THEN 'Free'
WHEN price < 5 THEN 'Under 5'
WHEN price < 15 THEN '5 to 15'
WHEN price < 30 THEN '15 to 30'
ELSE '30 or more' END AS price_band,
COUNT(*) AS n
FROM games WHERE game IS NOT NULL
GROUP BY price_band ORDER BY MIN(price) NULLS FIRST;
| price_band | n |
|---|---|
| Free | 3094 |
| Under 5 | 11613 |
| 5 to 15 | 9046 |
| 15 to 30 | 2367 |
| 30 or more | 565 |
%%{init: {"themeVariables": {"xyChart": {"plotColorPalette": "#4051b5"}}}}%%
xychart-beta
title "Games per price band"
x-axis ["Free", "Under 5", "5 to 15", "15 to 30", "30 or more"]
y-axis "Games" 0 --> 12000
bar [3094, 11613, 9046, 2367, 565]
Five rows, adding up to 26,685. Five bars instead of 199.
Notice the branch WHEN price < 15 THEN '5 to 15'. It does not say price >= 5 AND price < 15. It does not need to: a row only reaches that branch if price < 5 was not true. The order already does that job.
Break it twice, on purpose¶
The best way to understand the rules is to damage the query and watch.
Break 1 — move a branch to the top. Put WHEN price < 30 THEN '15 to 30' above all the others:
| price_band | n |
|---|---|
| 15 to 30 | 23026 |
| Free | 3094 |
| 30 or more | 565 |
Every game under 30 dollars stops at the first branch. Under 5 and 5 to 15 have vanished: 23,026 games now carry the wrong label.
Break 2 — delete the Free branch. Put the order back and remove the WHEN price IS NULL line:
| price_band | n |
|---|---|
| Under 5 | 11613 |
| 5 to 15 | 9046 |
| 30 or more | 3659 |
| 15 to 30 | 2367 |
The 3,094 free games fell to ELSE and joined the 565 expensive ones: 3,659 games "at 30 or more". Both broken queries run without a single error. This is the silent error of the types lesson again.
| Mistake | What happens | How to notice |
|---|---|---|
| Wide condition placed first | It swallows the rows meant for the branches below | Some bands are missing from the result |
No branch for NULL |
Empty cells land in ELSE |
The ELSE band is suspiciously big |
A typo in one label ('5 to 15 ') |
It becomes a category of its own | One band too many |
Sorting categories in their logical order¶
Why ORDER BY MIN(price) and not ORDER BY price_band? Because a label is text, and text sorts like a dictionary:
ORDER BY price_band |
ORDER BY MIN(price) NULLS FIRST |
|---|---|
| 15 to 30 | Free |
| 30 or more | Under 5 |
| 5 to 15 | 5 to 15 |
| Free | 15 to 30 |
| Under 5 | 30 or more |
Alphabetically, 15 to 30 comes first, because the character 1 comes before 3 and 5. To get the logical order you sort by something that still is a number: the smallest price inside each band. NULLS FIRST puts the free games, which have no price, at the top.
Building owners_tier¶
The second category describes the audience, using the owners_min built in the previous lesson. Three tiers:
| Tier | Rule | Meaning |
|---|---|---|
Niche |
owners_min under 20,000 |
The lowest range: between 0 and 20,000 owners |
Mid |
owners_min under 1,000,000 |
From 20,000 up to a million |
Hit |
everything else | A million owners or more |
A column created in a SELECT cannot be reused in that same SELECT, so the owners_min expression is written out again inside the CASE:
CASE WHEN REGEXP_REPLACE(SPLIT_PART(owners, '..', 1), '[^0-9]', '', 'g')::bigint < 20000
THEN 'Niche'
WHEN REGEXP_REPLACE(SPLIT_PART(owners, '..', 1), '[^0-9]', '', 'g')::bigint < 1000000
THEN 'Mid'
ELSE 'Hit' END AS owners_tier,
Added to the clean query (the block is now called step3) and counted with SELECT owners_tier, COUNT(*) AS n FROM step3 GROUP BY owners_tier ORDER BY MIN(owners_min);:
| owners_tier | n |
|---|---|
| Niche | 17921 |
| Mid | 8141 |
| Hit | 623 |
Two out of three games in the table are Niche. Only 623 out of 26,685 are a Hit.
Careful: remember the unit
Niche does not mean "almost nobody bought it". It means the estimate says "between 0 and 20,000 owners". A tier built on a lower bound inherits its unit.
Crossing two categories¶
Categories exist to be crossed. With both columns in step3, one query answers "which price band produces more hits?":
SELECT price_band, COUNT(*) AS games,
SUM(CASE WHEN owners_tier = 'Hit' THEN 1 ELSE 0 END) AS hits,
ROUND(100.0 * SUM(CASE WHEN owners_tier = 'Hit' THEN 1 ELSE 0 END) / COUNT(*), 1) AS hit_pct
FROM step3 GROUP BY price_band ORDER BY MIN(price) NULLS FIRST;
| price_band | games | hits | hit_pct |
|---|---|---|---|
| Free | 3094 | 182 | 5.9 |
| Under 5 | 11613 | 47 | 0.4 |
| 5 to 15 | 9046 | 168 | 1.9 |
| 15 to 30 | 2367 | 167 | 7.1 |
| 30 or more | 565 | 59 | 10.4 |
The SUM(CASE ... THEN 1 ELSE 0 END) is a counting trick: each hit adds 1, everything else adds 0. The share of hits goes from 0.4% among the cheapest paid games to 10.4% among the most expensive.
Careful: a higher price does not make a hit
The table shows that expensive games are hits more often. It does not show why. Big studios charge more and sell more: the size of the studio can explain both numbers. Two things moving together is not one causing the other.
Where do the edges go?¶
An edge is the value where one bin ends and the next begins.
Why < 5 and not <= 5? Look at the most common prices:
SELECT price, COUNT(*) FROM games GROUP BY price ORDER BY 2 DESC LIMIT 3;
| price | count |
|---|---|
| 9.99 | 3686 |
| 4.99 | 3204 |
| (empty) | 3095 |
Prices pile up just under the round number: 4.99, 9.99. So the edge goes on the round number, and everybody agrees which side 4.99 falls on.
| Good practice | Why |
|---|---|
| Put edges on round numbers | Easy to say, easy to remember, and prices cluster just below them |
Use one direction everywhere (< in every branch) |
No value can fall in two bins or in none |
Name the bins by their edges (5 to 15) |
The reader sees the rule in the label |
Give NULL its own bin |
Otherwise it hides inside ELSE |
Careful: the bands are not in the data
You drew the lines at 5, 15 and 30. Move a line and the story changes. A bin is a decision: it has an author and it must be written down in the report.
And a bin throws information away. Inside 30 or more, a game at 39.99 and a piece of software at 595.99 are now the same thing.
Messy categories¶
Text categories get dirty too. How many publishers are there?
SELECT COUNT(DISTINCT publisher) AS as_written,
COUNT(DISTINCT LOWER(publisher)) AS ignoring_case
FROM games;
| as_written | ignoring_case |
|---|---|
| 13951 | 13863 |
LOWER turns a text into small letters. Ignoring capitals, 88 "publishers" disappear: they were second spellings of one that already existed. Look at two of them:
SELECT publisher, COUNT(*) FROM games
WHERE LOWER(publisher) IN ('big fish games', 'capcom')
GROUP BY publisher ORDER BY 1;
| publisher | count |
|---|---|
| Big FIsh Games | 1 |
| Big Fish Games | 284 |
| CAPCOM | 1 |
| Capcom | 33 |
For you, two publishers. For a database, four. The same tool merges them:
CASE WHEN LOWER(publisher) = 'big fish games' THEN 'Big Fish Games'
ELSE publisher END AS publisher
That gives one Big Fish Games with 285 games. In the class table we do not fix the 88 spellings: we know about them, and that goes in the report. Knowing a limit and writing it down is also a decision.
Try it: bands and tiers¶
Open SQL → SQL Lab, database Dades classe, schema public. Run one statement at a time.
Step 1 — price_band. Run the five-band query on games. You should see five rows in the order Free, Under 5, 5 to 15, 15 to 30, 30 or more, adding up to 26,685.
Step 2 — Break it twice. Make the two changes described above, one at a time. Before fixing each one, write down the counts and one sentence saying where the games went.
Step 3 — Add both columns to the clean query. Open games_step2 under SQL → Saved Queries, rename the block step3, and add your price_band and the owners_tier expression. Mind the comma before each new CASE. Check with the owners_tier count: Niche 17,921, Mid 8,141, Hit 623.
Step 4 — Save it. Press Save, change the name to games_step3, choose Save as new.
Step 5 — Cross them. Keep the WITH step3 block and run the hits query. You should be able to say which band has the highest share of hits and which has the lowest.
The database cannot find the column price_band
A column created with AS cannot be used in the WHERE or in another column of the same SELECT. It only exists one level up, in the SELECT that reads from step3. That is the reason the clean query lives inside a WITH block.
Key takeaways¶
- A category makes a number readable: 199 prices become 5 bins. The number stays in its own column, because a label cannot be turned back into a number.
CASE WHENtries its branches top to bottom and stops at the first true one. The order is part of the logic.ELSEtakes everything else, includingNULL. Name theNULLcase first, withIS NULL.- A wrong order or a missing branch gives no error: check the counts of every band.
- Sort categories by a number (
ORDER BY MIN(price)), not by the label, which sorts alphabetically. - The edges are your decision. Put them on round numbers, use one direction, and write them down.
- Text categories get dirty too:
CapcomandCAPCOMare two values until you merge them.
Quick check¶
1. In a CASE WHEN with several branches, which branch does a row take?
- The last branch whose condition is true
- The first branch whose condition is true, reading from the top
- The branch with the narrowest condition
- All the branches whose conditions are true
A row tries the branches in order and stops at the first true one. That is why the order is part of the logic: a wide condition placed first swallows the rows meant for the branches below it.
2. With CASE WHEN price < 5 THEN 'Under 5' ELSE '5 or more' END, which label does Dota 2 (price NULL) get?
-
Under 5, because a free game costs less than 5 -
5 or more, becauseNULL < 5is not true and the row falls toELSE -
NULL, because the price is empty - The query gives an error
A comparison with NULL is never true: it is unknown. The row matches no branch and lands in ELSE, which takes everything else. A free game ends up labelled as expensive, with no error. The fix is a first branch WHEN price IS NULL THEN 'Free'.
3. You move WHEN price < 30 THEN '15 to 30' to the top of the price_band query. What happens?
- Nothing: the order of the branches does not matter
- The query stops with an error
- 23,026 games get the label
15 to 30, andUnder 5and5 to 15disappear - Only the games above 30 dollars change band
Every price under 30 is also true for price < 30, so all those rows stop at the first branch. The query runs and returns three bands instead of five. A missing band in the result is the clue.
4. Why is the result sorted with ORDER BY MIN(price) NULLS FIRST and not ORDER BY price_band?
- Because
ORDER BYcannot be used on aCASEcolumn - Because sorting by price is faster
- Because the labels are text and sort alphabetically:
15 to 30would come before5 to 15 - Because
price_bandcontainsNULLvalues
A label is text, and text sorts like a dictionary: 1 before 5. To get the logical order you sort by something that is still a number, such as the smallest price in each band.
5. Why is the edge written price < 5 and not price <= 5?
- Because
<=does not exist in PostgreSQL - Because no game costs exactly 5 dollars
- Because prices pile up just under round numbers (4.99, 9.99), so an edge on the round number separates them cleanly
- Because
<is faster than<=
The two most common prices are 9.99 and 4.99. Putting each edge on the round number, always with <, means every value falls in exactly one bin and everybody agrees on which side 4.99 is.
6. COUNT(DISTINCT publisher) gives 13,951 and COUNT(DISTINCT LOWER(publisher)) gives 13,863. What does the difference tell you?
- 88 publishers have no games
- 88 rows have an empty publisher
- 88 values are only a different spelling in capitals of a publisher that is already there
-
LOWERdeleted 88 rows from the table
LOWER turns the text into small letters, so CAPCOM and Capcom become the same value. The 88 missing "publishers" were never different companies: they are messy categories, one thing written two ways.
7. Someone writes CASE WHEN metascore >= 50 THEN 'Mixed' WHEN metascore >= 75 THEN 'Good' ELSE 'Bad' END. Pick the two things that are wrong with it.
- A game with a score of 90 gets
Mixed, because>= 50is true first: the>= 75branch must go above it -
CASEcannot be used on a numeric column - A game with no score gets
Bad, becauseNULLfalls toELSE: it needs its ownIS NULLbranch - The labels must be sorted alphabetically inside the
CASE
Both rules of this page at once. Run it on games and no game at all is Good, while 23,952 are Bad, almost all of them games that were simply never reviewed. Fixed (IS NULL first, then >= 75, then >= 50), the table has 23,838 with no score, 1,457 Mixed, 1,279 Good and only 114 Bad.
One of today's bands was not a price at all: Free was a guess about what an empty cell means. Were we allowed to make it? And what about 23,838 empty scores? Next: Imputation.