Imputation¶
Exercise imputation-annex.pdf · PDF
Your teacher is about to calculate the class average. Four students, three marks in the markbook: 6, 8 and 10. Your cell is empty. What should go in it?
A zero? Then the average drops from 8 to 6, and you have just failed an exam you were ill for. The class average? Then you get a mark for work nobody saw. Nothing at all? Then the average is quietly computed without you. Three options, three different averages, and not one of them is "obviously right". Every empty cell in a table asks this same question. This page is about answering it honestly.
What you will learn
- Count the missing values of a column and express them as a percentage of the rows
- Predict what
AVG,COUNTand+do when they meet aNULL - Replace a
NULLwithCOALESCEand keep a flag column that remembers which values were filled in - Turn a fake value back into a
NULLwithNULLIF - Choose between deleting the row, a constant, a central value and "leave it empty", and defend the choice in one sentence
- Avoid the trap: why filling 89% of a column with its mean destroys the column
A blank is not a zero¶
Four lessons of cleaning, and these cells of games are still empty:
| Column | Empty cells | Share of the rows |
|---|---|---|
metascore |
23,838 | 89.3% |
price |
3,095 | 11.6% |
developer |
152 | 0.6% |
publisher |
120 | 0.4% |
game |
3 | 0.01% |
You cannot clean an empty cell. There is nothing in it to repair. You can only decide what to do with it.
Imputation is deciding what value, if any, stands in for a missing one, and writing that decision down.
The analogy for the whole page is the teacher's markbook from the top: every decision you take here is one of its rows.
| Option | In the markbook | Fair when... |
|---|---|---|
| Delete the row | Take the student off the list | The row is useless without that value, and there are very few |
| A constant | Write a 0 | You know what the blank means: the work was never handed in |
| A central value | Write the class average, or the median | Few blanks, and nothing special about who has them |
| Leave it empty and flag it | Write nothing, add a note "not assessed" | You do not know, or most of the column is blank |
A zero is a mark. A blank is a question: why is it empty? The answer to that question, not the SQL, decides the option.
flowchart TB
Q1{"Do you know what<br>the blank means?"}
Q1 -- "Yes" --> C["<b>A constant</b><br>write what it means<br>and add a flag"]
Q1 -- "No" --> Q2{"Are the blanks few,<br>and spread at random?"}
Q2 -- "Yes" --> M["<b>A central value</b><br>mean or median<br>and add a flag"]
Q2 -- "No: many, or a special group" --> L["<b>Leave it NULL</b><br>and add a flag"]
Q1 -- "The row is useless without it" --> D["<b>Delete the row</b><br>only if they are very few"]
What NULL does to a calculation¶
Before filling anything, you need to know what the database does with an empty cell when you leave it alone. It does three different things, and none of them asks your permission.
| Operation | Meets a NULL |
Markbook example (6, 8, blank, 10) |
|---|---|---|
COUNT(*) |
Counts the row anyway | 4 students |
COUNT(column) |
Skips it | 3 marks |
AVG(column), SUM(column) |
Skip it | Average 8: it divides 24 by 3, not by 4 |
column + 1, column < 5 |
The result is NULL |
blank + 1 = blank |
See it on the real table. The same column, two averages:
SELECT ROUND(AVG(price)::numeric, 2) AS avg_ignoring_null,
ROUND(AVG(COALESCE(price, 0))::numeric, 2) AS avg_free_as_zero
FROM games;
| avg_ignoring_null | avg_free_as_zero |
|---|---|
| 8.95 | 7.91 |
Which one is "the average price of a game on Steam"? It depends on what the blank means. AVG silently skipped the empty rows: 8.95 is a sum divided by 23,593 games, not by 26,688. Nobody told it to.
Careful: AVG does not count the empty ones as 0
This is the most common wrong guess. AVG skips a NULL; it does not treat it as zero. That is exactly why the two averages differ. Whenever you publish an average, say how many rows it was computed on.
COALESCE: the value and the note in the margin¶
COALESCE(a, b, ...)returns the first of its arguments that is notNULL. Read it aloud as "a, and if it is empty,b".
SELECT game,
COALESCE(price, 0)::numeric(8,2) AS price, -- the value we filled in
(price IS NULL) AS is_free -- the note in the margin
FROM games
WHERE game IN ('Dota 2', 'Half-Life 2');
| game | price | is_free |
|---|---|---|
| Dota 2 | 0.00 | true |
| Half-Life 2 | 9.99 | false |
A flag column is an extra column, true or false, that records something about the row. Here: "this value was empty in the original data".
The two always travel in pairs: the filled value and a flag that remembers it was empty. Once the NULL is overwritten, nobody can tell a real 0 from an invented one. The flag is the note the teacher writes in the margin of the markbook.
Careful: COALESCE did not fix the table
Like CAST, it changes the result of this query. games is untouched. The fill only becomes permanent when the clean query is turned into the table games_clean, in the next lesson.
The replacement must have the same type as the column. COALESCE(developer, 'Unknown') works: text for text. COALESCE(developer, 0) stops with COALESCE types text and integer cannot be matched. A loud error, the good kind.
price: a constant you can defend¶
The categorisation lesson labelled the games with no price as Free. That was an assumption. Now check whether you were allowed to make it. Look for evidence before filling anything:
SELECT COUNT(*) FROM games WHERE price = 0; -- 0
SELECT MIN(price) FROM games; -- 0.49
SELECT game, owners FROM games
WHERE price IS NULL ORDER BY number LIMIT 4;
| game | owners |
|---|---|
| Team Fortress 2 | 50,000,000 .. 100,000,000 |
| Dota 2 | 100,000,000 .. 200,000,000 |
| Divinity: Original Sin (Classic) | 1,000,000 .. 2,000,000 |
| BioShock 2 | 2,000,000 .. 5,000,000 |
Three pieces of evidence:
| Evidence | What it tells you |
|---|---|
| No game costs exactly 0 | The free games must be somewhere else |
| The cheapest price on record is 0.49 | Zero is never written as a price |
| The blanks start with Team Fortress 2 and Dota 2 | Famous free-to-play games have a blank price |
Domain knowledge is what you know about the real world behind the data: how Steam works, what a free-to-play game is. No query can give it to you.
Domain knowledge says a game with no price on Steam is free to play, and the data agrees. Decision: a constant, 0, plus the flag is_free.
The honest footnote
Read the list again. BioShock 2 has no price either, and so does Ori and the Blind Forest a little further down. Neither was ever a free game. Does that break the rule? It weakens it; it does not break it.
Imputation is an assumption written in SQL. A good analyst writes the assumption next to the number: "price 0 means no price listed, almost always free to play". That sentence belongs in the report.
So which average do you publish? Both are correct answers to different questions:
| Question | Average | Computed on |
|---|---|---|
| "How much does a game on Steam cost?" | 7.91 | All 26,688 games, free ones as 0 |
| "How much does a paid game cost?" | 8.95 | The 23,593 games with a price |
metascore: why you do not fill it¶
Now the big hole: 89.3% of the scores are missing. The tempting move is a central value.
The mean is the sum of the values divided by how many there are. The median is the value in the middle when you sort them. Both describe a "typical" value; you will study them properly in Exploratory statistics.
The mean metascore is 71.9 and the median is 73. So fill the blanks with 72, and count:
SELECT COUNT(*) FROM games WHERE metascore = 72; -- 114
SELECT COUNT(*) FROM games WHERE COALESCE(metascore, 72) = 72; -- 23952
114 games really scored 72. After filling, 23,952 games "scored" 72. Look at what that does to the column:
The dangerous part: the mean barely moves. It was 71.9 and it becomes 72.0. The average hides the damage. Everything else about the column (how spread out the scores are, which games are good, which are bad) has been flattened into one spike.
Careful: filling with the mean is not neutral
It keeps the mean and destroys everything else. Nine out of ten values in the column would be numbers you invented. A chart of that column no longer shows review scores. It shows your imputation.
Who is missing?¶
There is a second reason, and it is the stronger one. Check which games have no score. Small games first:
SELECT (owners LIKE '0%') AS under_20k_owners,
COUNT(*) AS games,
COUNT(metascore) AS with_score,
ROUND(100.0 * COUNT(metascore) / COUNT(*), 1) AS pct_with_score
FROM games GROUP BY 1;
| under_20k_owners | games | with_score | pct_with_score |
|---|---|---|---|
| false | 8765 | 2363 | 27.0 |
| true | 17923 | 487 | 2.7 |
Among games with 20,000 owners or more, 27.0% have a score. Among the smallest ones, 2.7%. Ten times less. Critics review the games people have heard of.
Values are missing at random when a blank is equally likely in any row. They are not missing at random when the blanks pile up in one particular group.
Here they pile up in the small, unknown games. Writing 72 in those cells would say "a decent game, according to the critics" about 23,838 games that no critic ever opened. And writing 0 would be worse: "the worst game ever reviewed".
Decision: leave it NULL, and add the flag (metascore IS NOT NULL) AS has_metascore. Any chart of scores must then say "based on the 11% of games that were reviewed".
price |
metascore |
|
|---|---|---|
| Empty | 11.6% | 89.3% |
| Do we know what the blank means? | Yes: free to play | No: nobody reviewed it |
| Missing at random? | Not relevant: we know the value | No: small games are missing |
| Decision | Constant 0 + is_free |
Leave NULL + has_metascore |
Same SQL tool, opposite verdict.
Text columns: a label for the blank¶
A text column cannot take a mean. For developer and publisher, give the blank its own category, so that GROUP BY shows it instead of hiding it:
SELECT COALESCE(developer, 'Unknown') AS developer, COUNT(*) AS games
FROM games GROUP BY 1 ORDER BY games DESC LIMIT 3;
| developer | games |
|---|---|
| Unknown | 152 |
| Choice of Games | 92 |
| KOEI TECMO GAMES CO., LTD. | 70 |
Careful: Unknown is not a studio
According to this result, the biggest developer in the table is Unknown, with 152 games. It is a trap: those are 152 different games whose developer we do not know, not one studio that made 152. The label keeps the blanks visible. It does not turn them into a company.
NULLIF: when a value should be a blank¶
The opposite problem also exists: a value that should be empty and is not. The audit found it in average_playtime: 0 for 96.5% of the games, because nobody measured it.
NULLIF(x, y)returnsNULLwhenxequalsy, andxotherwise. It turns a fake value back into an honest blank.
SELECT ROUND(AVG(average_playtime)::numeric, 1) AS with_zeros,
ROUND(AVG(NULLIF(average_playtime, 0))::numeric, 1) AS zeros_as_null,
COUNT(NULLIF(average_playtime, 0)) AS measured
FROM games;
| with_zeros | zeros_as_null | measured |
|---|---|---|
| 9.1 | 262.1 | 922 |
An average of 9.1 with the fake zeros, 262.1 without them, computed on the 922 games that were actually measured. Almost thirty times bigger. The zeros were dragging the average down to nothing.
| Function | Direction | Use it when |
|---|---|---|
COALESCE(x, value) |
Blank → value | You know what the blank means |
NULLIF(x, value) |
Value → blank | A value is only a costume for "no data" |
The imputation log¶
An imputation log is the written record of every imputation decision: column, how much was missing, what you did, and why.
This is the one for games. Every line is a row of the markbook:
| Column | Missing | Decision | Why |
|---|---|---|---|
price |
3,095 (11.6%) | Constant 0, plus flag is_free |
Domain knowledge: no price on Steam means free to play. No row has price 0, and the blanks include Dota 2 and Team Fortress 2 |
metascore |
23,838 (89.3%) | Leave NULL, plus flag has_metascore |
Too many, and not missing at random: 27.0% of the bigger games have a score, 2.7% of the smallest. Any filled value would be invented |
developer, publisher |
152 and 120 (under 1%) | Constant 'Unknown' |
A category, not a number. The label keeps the rows visible in every GROUP BY |
game |
3 (0.01%) | Delete the rows | A game without a name cannot be identified or imputed. 3 rows out of 26,688 change nothing |
Careful: deleting is the last resort, not the first
Deleting every row with a blank is the fastest way to "clean" and usually the wrong one. Drop every game without a metascore and you keep 2,850 of 26,688 rows: your conclusions would describe a smaller, more famous world. Deleting is fair for 3 nameless rows. It is not fair for 89% of a table.
Try it: write your imputation log¶
Open SQL → SQL Lab, database Dades classe, schema public. Every query is a SELECT; nothing gets modified. Run one statement at a time.
Step 1 — Measure the hole. Run this for price, then repeat it for metascore, developer and publisher:
SELECT COUNT(*) AS total_rows,
COUNT(price) AS with_value,
COUNT(*) - COUNT(price) AS missing,
ROUND(100.0 * (COUNT(*) - COUNT(price)) / COUNT(*), 1) AS pct_missing
FROM games;
You should see 11.6, 89.3, 0.6 and 0.4.
Step 2 — price. Run the three evidence queries, then the two-averages query. Write which average you would publish, and for which question.
Step 3 — metascore. Run the two counts with 72 and the "who is missing" query. Write one sentence: why is 72 a lie for a game nobody reviewed?
Step 4 — Text columns. Run the COALESCE(developer, 'Unknown') query. Who is the "biggest developer", and why is that a trap?
Step 5 — The log. Write one line per column: delete, constant, central value, or leave empty and flag.
pct_missing shows 0 or 11 instead of 11.6
Integer division. 100 * 3095 / 26688 with whole numbers gives a whole number. Write 100.0 *, with the decimal, before dividing, and the decimals come back.
function round(double precision, integer) does not exist
ROUND(x, 2) with a number of decimals only works on the type numeric. Add ::numeric before rounding: ROUND(AVG(price)::numeric, 2).
Key takeaways¶
- Imputation is deciding what stands in for a missing value. It is an assumption written in SQL, so it must be justified and written down.
AVG,SUMandCOUNT(column)skipNULL; arithmetic and comparisons withNULLgiveNULL. An average hides how many rows it was computed on.COALESCE(x, value)fills a blank; always pair it with a flag column that remembers the value was empty.NULLIF(x, value)does the opposite.- Use a constant when you know what the blank means (
price: free means 0), with domain knowledge and evidence. - Use a central value (mean or median) only for few blanks that are missing at random.
- Leave it
NULLand flag it when you do not know or most of the column is empty: fillingmetascorewith 72 turns 114 real values into 23,952. - Delete rows only when they are useless and very few.
Quick check¶
1. AVG(price) on games returns 8.95 and AVG(COALESCE(price, 0)) returns 7.91. Why are they different?
- Because
COALESCErounds the prices down - Because
AVGcounts everyNULLas 0 - Because
AVGskips theNULLrows, so the first average divides by fewer games - Because one of the two queries is wrong
AVG skips empty cells: 8.95 is computed on the 23,593 games with a price. With COALESCE, the 3,095 blanks become 0 and enter the calculation, so the sum is divided by all 26,688 rows. Both are correct answers to different questions.
2. What does COALESCE(price, 0) return for a game whose price is 9.99?
- 0
- 9.99
-
NULL - 9.99 and 0, in two rows
COALESCE returns the first argument that is not NULL. The price exists, so it is returned untouched. Only the empty prices become 0.
3. Why do we add the column is_free next to the filled price?
- Because
COALESCEdoes not work without it - Because it makes the query faster
- Because once the
NULLis replaced by 0, nothing else remembers that the value was empty - Because prices cannot be 0 in PostgreSQL
A flag column is the note in the margin of the markbook. After the fill, an invented 0 and a real 0 look the same. The flag keeps the difference, so anybody can undo or question the decision later.
4. Why do we leave metascore empty instead of filling it with the mean, 72?
- Because
COALESCEcannot fill a numeric column - Because the mean of the column would change a lot
- Because 89.3% of the column is missing and not at random, so nine out of ten values would be invented
- Because 72 is too low a score for most games
Filling turns 114 real scores of 72 into 23,952, one giant spike, while the mean barely moves (71.9 to 72.0) and hides the damage. And the blanks are not missing at random: they pile up in small games no critic reviewed.
5. What does NULLIF(average_playtime, 0) do?
- It replaces every
NULLby 0 - It deletes the rows where the playtime is 0
- It returns
NULLwhere the playtime is 0, and the playtime everywhere else - It counts the rows where the playtime is 0
NULLIF is the opposite of COALESCE: it turns a value that only means "no data" back into an honest blank. The average playtime goes from 9.1 to 262.1, now computed on the 922 games that were really measured.
6. After COALESCE(developer, 'Unknown'), the developer with the most games is Unknown, with 152. What is the correct reading?
- A studio called Unknown made 152 games
- 152 different games have no developer on record; they are not one studio
- The query is wrong and must be deleted
- 152 games have the developer spelled in two ways
The label keeps the blanks visible in a GROUP BY instead of hiding them. But it is a category for "we do not know", not a company. Ranking it as the biggest developer would be a mistake of interpretation, not of SQL.
7. A table of gym members has an empty age in 4% of the rows and an empty cancellation_date in 80% of the rows. Pick the two decisions you can defend.
-
age: fill with the median age and add a flag, because the blanks are few and nothing suggests they are a special group -
cancellation_date: fill the blanks with the most common date -
cancellation_date: leave itNULLand add a flag such asis_active, because empty means "has not cancelled" - Delete every member with any empty cell
Two blanks, two stories. Few and at random: a central value plus a flag is fair. The empty cancellation date means something (the member is still active), so a date there would invent 80% of the column. Deleting would throw away most of the gym.
The empty cells now have a decision and a written reason. You have stopped repairing columns; it is time to invent new ones. Next: Synthetic variables, where the clean table games_clean is finally built.