Skip to content

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 WHEN with several branches and predict which branch a given row takes
  • Explain why the order of the branches matters, and where a NULL ends up
  • Build price_band and owners_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 WHEN is 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 WHEN tries its branches top to bottom and stops at the first true one. The order is part of the logic.
  • ELSE takes everything else, including NULL. Name the NULL case first, with IS 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: Capcom and CAPCOM are 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, because NULL < 5 is not true and the row falls to ELSE
  • 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, and Under 5 and 5 to 15 disappear
  • 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 BY cannot be used on a CASE column
  • Because sorting by price is faster
  • Because the labels are text and sort alphabetically: 15 to 30 would come before 5 to 15
  • Because price_band contains NULL values

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
  • LOWER deleted 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 >= 50 is true first: the >= 75 branch must go above it
  • CASE cannot be used on a numeric column
  • A game with no score gets Bad, because NULL falls to ELSE: it needs its own IS NULL branch
  • 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.