Skip to content

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, COUNT and + do when they meet a NULL
  • Replace a NULL with COALESCE and keep a flag column that remembers which values were filled in
  • Turn a fake value back into a NULL with NULLIF
  • 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 not NULL. 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:

Two histograms of metascore. On the left, the 2,850 real scores form a hill between 20 and 98 with its peak near 80. On the right, after filling the blanks with 72, a single red bar of 23,952 games at 72 makes every other score invisible.

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) returns NULL when x equals y, and x otherwise. 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, SUM and COUNT(column) skip NULL; arithmetic and comparisons with NULL give NULL. 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 NULL and flag it when you do not know or most of the column is empty: filling metascore with 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 COALESCE rounds the prices down
  • Because AVG counts every NULL as 0
  • Because AVG skips the NULL rows, 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 COALESCE does not work without it
  • Because it makes the query faster
  • Because once the NULL is 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 COALESCE cannot 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 NULL by 0
  • It deletes the rows where the playtime is 0
  • It returns NULL where 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 it NULL and add a flag such as is_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.