Synthetic variables¶
Exercise synthetic-variables-annex.pdf · PDF
You buy three things at the supermarket and look at the receipt. The price of each product was on the shelf. But the line that says "3 × 1.20 = 3.60", and the TOTAL at the bottom? Nobody stuck those on a shelf. The till worked them out.
Your data is the same. The file about Steam games tells you how many people own each game and what it costs. It does not tell you how much money each game moved, or what share of the catalogue is free. Those answers are not in the table. You have to make them. In the crafting game this is the moment you stop sorting loot and start crafting: new items, built out of the ones you already have.
What you will learn
- What a synthetic variable is, and why it is not "invented data"
- How to store your cleaning as one table,
games_clean, with a column the database computes on its own - The difference between a calculated column (one value per row) and a metric (one value per group)
- How to add both to a dataset in Superset
- How a window function writes a group's value next to every row, and gives you a share of the total in one query
- The trap: why
est_revenueis not what a game really earned
Columns nobody collected¶
A synthetic variable is a column that nobody collected. It is computed from the columns that were.
"Synthetic" does not mean fake. It means put together. The line total on a receipt is not a lie: it is 3 × 1.20, and anyone can check it. A synthetic variable is honest for the same reason: it comes with a recipe.
You have already made some without calling them that. In Categorisation you built price_band out of price. In Imputation you built is_free and has_metascore. Today we name the idea and learn the three ways to do it. We will keep the receipt for the whole page:
| On the receipt | What it is | In SQL and Superset |
|---|---|---|
| Line total (units × price) | A new value for every row, made from that row only | Calculated column: owners_min * price |
TOTAL at the bottom |
One value for the whole group; the lines disappear | Metric: SUM(owners_min * price) |
| "This line is 40% of your receipt" | A group value written next to every row; the lines stay | Window function: SUM(...) OVER () |
flowchart LR
R["<b>Rows of the table</b><br>one game per row"]
R --> C["<b>Calculated column</b><br>the line total:<br>26,685 rows in, 26,685 rows out"]
R --> M["<b>Metric</b><br>the TOTAL:<br>26,685 rows in, 1 row per group out"]
R --> W["<b>Window function</b><br>the share of the receipt:<br>26,685 rows in, 26,685 rows out,<br>each with its group's value"]
First, make the cleaning permanent¶
Every fix you wrote in this unit was a SELECT. It showed a clean result and vanished when you closed the tab. The table games never changed. Before crafting anything new, you store the cleaning as a real table.
You already know the tool from Datasets and destination: CREATE TABLE ... AS SELECT saves the result of a query as a new table. The class script games_clean.sql puts every cleaning step of the unit into one statement. Read it and you will recognise every line:
| Line of the script | Where you learned it |
|---|---|
TRIM(game), and release_date from text to a real DATE with TO_DATE |
Types and formats |
owners → owners_min: SPLIT_PART + REGEXP_REPLACE + ::bigint |
Units and duplicates |
price_band and owners_tier with CASE WHEN |
Categorisation |
COALESCE(price, 0) + is_free, 'Unknown', has_metascore, WHERE game IS NOT NULL |
Imputation |
ALTER TABLE ... ADD COLUMN release_year ... GENERATED ALWAYS AS (...) STORED |
Today |
The whole script, games_clean.sql
DROP TABLE IF EXISTS games_clean;
CREATE TABLE games_clean AS
SELECT
number,
TRIM(game) AS game,
COALESCE(NULLIF(TRIM(developer), ''), 'Unknown') AS developer,
COALESCE(NULLIF(TRIM(publisher), ''), 'Unknown') AS publisher,
CASE WHEN release_date ~ '^[A-Z][a-z]{2} [0-9]{1,2}, [0-9]{4}$' THEN TO_DATE(release_date, 'Mon DD, YYYY')
WHEN release_date ~ '^[A-Z][a-z]{2} [0-9]{4}$' THEN TO_DATE(release_date, 'Mon YYYY')
END AS release_date,
COALESCE(price, 0)::numeric(8,2) AS price,
(price IS NULL) AS is_free,
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,
REGEXP_REPLACE(SPLIT_PART(owners, '..', 1), '[^0-9]', '', 'g')::bigint AS owners_min,
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,
average_playtime, median_playtime,
metascore,
(metascore IS NOT NULL) AS has_metascore
FROM games WHERE game IS NOT NULL;
ALTER TABLE games_clean ADD COLUMN release_year int
GENERATED ALWAYS AS (EXTRACT(YEAR FROM release_date)::int) STORED;
It starts with DROP TABLE IF EXISTS, so you can run it as many times as you like: it always rebuilds the table from zero.
The last line is the first synthetic variable of the day.
A generated column is a column the database computes by itself from other columns of the same row, and keeps up to date.
Nobody types a release_year. PostgreSQL reads release_date and writes the year next to it:
SELECT game, release_date, release_year
FROM games_clean ORDER BY number LIMIT 3;
| game | release_date | release_year |
|---|---|---|
| Half-Life 2 | 2004-11-16 | 2004 |
| Sid Meier's Civilization IV | 2005-10-25 | 2005 |
| Company of Heroes | 2006-09-11 | 2006 |
Check that your table is complete:
SELECT COUNT(*) FROM games_clean;
The answer is 26,685: the 26,688 rows of games minus the three games with no name.
Then register the table so Superset can chart it: Datasets → + Dataset → Database Dades classe, Schema public, Table games_clean → Create dataset (the arrow next to Create and explore dataset).
Keep this table
games_clean is the table every chart in Unit 3 and every dashboard in Unit 4 is drawn from. Build it once, check the 26,685, and leave it alone.
The line total: a calculated column¶
A calculated column is a new column whose value is worked out row by row, using only the values of that same row.
On the receipt it is "3 × 1.20 = 3.60". The till needs nothing but that one line to print it.
Our line total: how much money did a game move, roughly? Take the owners and multiply by the price. We call it est_revenue, for estimated revenue.
SELECT game, owners_min, price, owners_min * price AS est_revenue
FROM games_clean ORDER BY est_revenue DESC LIMIT 5;
| game | owners_min | price | est_revenue |
|---|---|---|---|
| PLAYERUNKNOWN'S BATTLEGROUNDS | 50,000,000 | 29.99 | 1,499,500,000.00 |
| MONSTER HUNTER: WORLD | 5,000,000 | 59.99 | 299,950,000.00 |
| Sid Meier's Civilization V | 10,000,000 | 29.99 | 299,900,000.00 |
| Grand Theft Auto V | 10,000,000 | 29.99 | 299,900,000.00 |
| ARK: Survival Evolved | 5,000,000 | 49.99 | 249,950,000.00 |
Read the first figure out loud: one thousand four hundred and ninety-nine million. About 1.5 billion dollars. A number that big deserves a second look, and we will give it one at the end of this page.
In SQL Lab that column lives only inside your query. To have it ready for every future chart, add it to the dataset:
| Step | Do this | You should see |
|---|---|---|
| 1 | Datasets → on the row games_clean, click the pencil under Actions (Edit) |
The dataset editor |
| 2 | Tab Calculated columns → Add item | A new empty row |
| 3 | Name the column est_revenue |
|
| 4 | In SQL expression, delete the sample text and type owners_min * price |
|
| 5 | Data type: NUMERIC |
|
| 6 | Save → OK | est_revenue appears among the dataset's columns |
Other calculated columns follow the same pattern. The price with 21% VAT is price * 1.21: one row in, one value out.
The TOTAL: a metric¶
A metric is a calculation over many rows that gives back one value per group. It is always an aggregate:
SUM,AVG,COUNT,MIN,MAX.
On the receipt it is the TOTAL. The lines disappear and one number is left. You met aggregates in SQL Lab: groups; a metric is an aggregate with a name, saved in the dataset so you never retype it.
Add two, in the same editor: tab Metrics → Add item, twice.
| Metric key | SQL expression | What it answers |
|---|---|---|
total_est_revenue |
SUM(owners_min * price) |
How much estimated revenue is there in total? |
pct_free |
100.0 * SUM(CASE WHEN is_free THEN 1 ELSE 0 END) / COUNT(*) |
What percentage of the games is free? |
Then Save → OK.
Look at pct_free. CASE WHEN is_free THEN 1 ELSE 0 END turns every game into a 1 or a 0. Adding them up counts the free ones. Dividing by COUNT(*) makes it a share. The 100.0, with its decimal point, stops PostgreSQL from doing whole-number division and answering 0.
A metric has no fixed size. It answers for whatever group you hand it. For the whole table:
SELECT SUM(owners_min * price) AS total_est_revenue,
ROUND(100.0 * SUM(CASE WHEN is_free THEN 1 ELSE 0 END) / COUNT(*), 1) AS pct_free
FROM games_clean;
| total_est_revenue | pct_free |
|---|---|
| 26,685,821,600.00 | 11.6 |
And with a GROUP BY owners_tier, the same two metrics answer three times:
SELECT owners_tier,
SUM(owners_min * price) AS total_est_revenue,
ROUND(100.0 * SUM(CASE WHEN is_free THEN 1 ELSE 0 END) / COUNT(*), 1) AS pct_free
FROM games_clean GROUP BY owners_tier ORDER BY owners_tier;
| owners_tier | total_est_revenue | pct_free |
|---|---|---|
| Hit | 18,256,160,000.00 | 29.2 |
| Mid | 8,429,661,600.00 | 18.5 |
| Niche | 0.00 | 7.8 |
Almost three in ten Hits are free. And the Niche tier earned... zero? Hold that thought.
Careful: the metric cannot see your calculated column
It is tempting to write the metric as SUM(est_revenue). Superset saves it without complaint. Then the chart fails: column "est_revenue" does not exist.
The calculated column lives in Superset's dataset, not in the PostgreSQL table. The till prints the line total; the shelf label never had it. Write the metric with the full expression: SUM(owners_min * price).
The opposite mistake is just as wrong: never put an aggregate inside a calculated column. A line of the receipt cannot contain the TOTAL.
The share of the receipt: window functions¶
Sometimes you want both things at once: every row, and the total next to it. "This line is 40% of your receipt." With GROUP BY you cannot: the rows are gone.
A window function computes a value over a group of rows and writes it next to every row, without collapsing them. You recognise it by the word
OVER.
Read OVER () as "over all the rows, without collapsing them". Here it gives the share of each price band:
SELECT price_band, COUNT(*) AS games,
ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct_of_games
FROM games_clean GROUP BY price_band ORDER BY games DESC;
| price_band | games | pct_of_games |
|---|---|---|
| Under 5 | 11,613 | 43.5 |
| 5 to 15 | 9,046 | 33.9 |
| Free | 3,094 | 11.6 |
| 15 to 30 | 2,367 | 8.9 |
| 30 or more | 565 | 2.1 |
COUNT(*) is the line. SUM(COUNT(*)) OVER () is the TOTAL, printed on every line: 26,685 five times. Divide one by the other and you have a share of the total in one query, with no subquery.
A window can also be cut into groups.
PARTITION BYis theGROUP BYof a window: it splits the rows into groups, but the rows stay.
This query compares each game with the average price of its own release year:
SELECT game, release_year, price,
ROUND(AVG(price) OVER (PARTITION BY release_year), 2) AS year_avg,
ROUND(price - AVG(price) OVER (PARTITION BY release_year), 2) AS vs_year_avg
FROM games_clean ORDER BY owners_min DESC, number LIMIT 5;
| game | release_year | price | year_avg | vs_year_avg |
|---|---|---|---|---|
| Dota 2 | 2013 | 0.00 | 11.21 | -11.21 |
| Team Fortress 2 | 2007 | 0.00 | 8.59 | -8.59 |
| PLAYERUNKNOWN'S BATTLEGROUNDS | 2017 | 29.99 | 7.69 | 22.30 |
| Counter-Strike: Global Offensive | 2012 | 0.00 | 10.96 | -10.96 |
| Paladins | 2018 | 0.00 | 7.12 | -7.12 |
Five rows, five games, and each one carries the average of its year. PUBG cost 22.30 dollars more than the average 2017 game. vs_year_avg is a synthetic variable built from an aggregate calculation.
GROUP BY |
Window function | |
|---|---|---|
| Rows you get back | One per group | Every row |
| Where the group value goes | It replaces the rows | It sits beside each row |
| Keyword | GROUP BY publisher |
OVER (PARTITION BY publisher) |
| On the receipt | The TOTAL |
"This line is 40% of your receipt" |
Careful: WHERE runs before the window
If you filter with WHERE, the "total" is the total of what is left. Add WHERE price_band <> 'Free' to a share query and the percentages still add up to 100, but it is 100% of the paid games, not of the catalogue. Always say out loud what your 100% is.
What est_revenue is not¶
Now the second look we promised. Here is the share of games and the share of estimated revenue per tier:
SELECT owners_tier, COUNT(*) AS games,
ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct_games,
ROUND(100.0 * SUM(owners_min * price) / SUM(SUM(owners_min * price)) OVER (), 1) AS pct_revenue
FROM games_clean GROUP BY owners_tier ORDER BY pct_revenue DESC;
| owners_tier | games | pct_games | pct_revenue |
|---|---|---|---|
| Hit | 623 | 2.3 | 68.4 |
| Mid | 8,141 | 30.5 | 31.6 |
| Niche | 17,921 | 67.2 | 0.0 |
623 games out of 26,685 take two thirds of the money. That is a real and interesting pattern.
But 17,921 games earned nothing? No. Remember where owners_min came from: the bottom of a range. The original file says "0 .. 20,000", and the bottom of that range is 0. Zero owners times any price is zero.
The variable is only as good as the columns it was made from. So est_revenue must travel with its label:
| What it is | What it is not |
|---|---|
| The lowest number of owners × the price on the day the data was collected | What the game really earned |
| A floor, useful to compare games with each other | An amount you can quote in euros or dollars |
| 0 for every free game, Dota 2 included | Proof that free games make no money |
| Blind to sales, discounts and price changes | A sales record |
A synthetic variable without its recipe is a rumour
Whenever you create a column, write down how. est_revenue = owners_min * price, where owners_min is the lower end of an estimate. With that line, a reader can judge your number. Without it, they can only believe it or not.
Try it¶
Use games_clean in SQL Lab (database Dades classe, schema public). The original file, video_games.csv, is on the Resources page.
Step 1 — Add the calculated column est_revenue and the metrics total_est_revenue and pct_free to your dataset, following the tables above.
Step 2 — Predict first, then run: which tier has the highest pct_free? Check with the GROUP BY owners_tier query.
Step 3 — Finish the sentence with the last query: "The Hit tier is __% of the games and __% of the estimated revenue."
Step 4 — Rank inside a group. RANK() is a window function that numbers the rows of each group. Who are the most-owned games of 2017?
SELECT game, owners_min,
RANK() OVER (PARTITION BY release_year ORDER BY owners_min DESC) AS position
FROM games_clean WHERE release_year = 2017 ORDER BY position LIMIT 4;
You should get PLAYERUNKNOWN'S BATTLEGROUNDS (1), Unturned (2), and Robocraft and Brawlhalla sharing position 3: they tie at 10,000,000.
Step 5 — Write one reason why est_revenue is not what a game really earned. If you can, you understood the lesson.
Key takeaways¶
- A synthetic variable is a column nobody collected: it is computed from the ones that were. It is honest as long as it carries its recipe.
CREATE TABLE ... AS SELECTmakes the cleaning permanent ingames_clean(26,685 rows). A generated column such asrelease_yearis computed by the database itself.- A calculated column gives one value per row (
owners_min * price). A metric gives one value per group (SUM(owners_min * price)). - A metric must use the full expression: Superset's calculated columns do not exist in the PostgreSQL table.
- A window function (
OVER,PARTITION BY) writes a group value next to every row.COUNT(*) / SUM(COUNT(*)) OVER ()is a share of the total. est_revenueis a floor, not an income: a variable is only as good as the columns it was made from.
Quick check¶
1. What is a synthetic variable?
- A column filled with made-up values to hide missing data
- A column nobody collected, computed from the columns that were
- A column copied from another table with a
JOIN - A column whose values were typed by hand after the upload
Synthetic means put together, not fake. release_year, price_band and est_revenue were never in the file: each one is computed from existing columns with a recipe anyone can check.
2. On the supermarket receipt, which part is the metric?
- The price printed on the shelf label
- The line total, units × price
- The TOTAL at the bottom
- The note "this line is 40% of your receipt"
A metric is an aggregate: many rows go in and one value per group comes out, like the TOTAL. The line total is a calculated column and the 40% note is a window function.
3. You created the calculated column est_revenue in the dataset editor and then a metric SUM(est_revenue). The chart fails, saying that the column est_revenue does not exist. Why?
- Because
SUMdoes not work onNUMERICcolumns - Because a dataset can have a calculated column or a metric, but not both
- Because the calculated column lives in Superset's dataset, not in the PostgreSQL table
- Because metric names cannot contain an underscore
The query is sent to PostgreSQL, and the table games_clean has no column called est_revenue: that column exists only in Superset's dataset. Write the metric with the full expression, SUM(owners_min * price).
4. What is the difference between GROUP BY publisher and OVER (PARTITION BY publisher)?
- There is none: they are two spellings of the same thing
-
GROUP BYworks on numbers andPARTITION BYworks on text -
PARTITION BYdeletes the rows that do not belong to a group -
GROUP BYreturns one row per publisher; the window returns every row with its publisher's value beside it
Both split the rows into groups. GROUP BY collapses each group into one row. A window function keeps every row and writes the group's value next to it, which is why you can subtract a game's price from its year's average.
5. In the share query, what does SUM(COUNT(*)) OVER () give you?
- The number of games in each price band
- The total number of games, repeated on every row of the result
- The number of price bands
- A running total that grows row by row
COUNT(*) is the line: the games in one band. SUM(COUNT(*)) OVER () adds those counts over all the rows and prints the result, 26,685, on each one. Dividing the first by the second gives the share of the total.
6. est_revenue says the 17,921 Niche games earned 0 in total. What is the right conclusion?
- Niche games are all free
- The query has a bug in the
SUM -
owners_minis the bottom of the range0 .. 20,000, so the variable is a floor, not real income - Steam does not pay small developers
The arithmetic is right: 0 owners × any price = 0. The problem is the ingredient. owners_min is the lower end of an estimate, and for the smallest range that end is 0. A synthetic variable is only as good as the columns it was made from.
7. For a new dataset you need: the price of each game with 21% VAT, and the average playtime per publisher. Pick the two correct choices.
- The price with VAT is a calculated column:
price * 1.21 - The average playtime per publisher is a metric:
AVG(average_playtime), grouped by publisher - The price with VAT is a metric, because it uses a multiplication
- The average playtime per publisher is a calculated column, because every game has a publisher
Ask "how many values do I get?". The VAT price is one value per row, from that row only: a calculated column. The average is one value per group: a metric. If you wanted each game's position among the games of its publisher, you would need a window function: a group calculation that keeps every row.
games_clean now exists, with columns that were never in the file. Next you rebuild the same cleaning without writing a line of SQL: Power Query, where you click and the tool writes the recipe for you.