Features And Feature Stores

Categorical Features at Scale: One-Hot, Hashing or Target Encoding?

0 of 25 complete

0%

Contents

Back|Features And Feature StoresCategorical Features at Scale: One-Hot, Hashing or Target Encoding?
1/25
66 min left
Prerequisites
What a Feature Is: A Better Model or a Better Feature?requiredPoint-in-Time Joins: A Latest-Value Join Promised 0.714 and Delivered 0.301required
Related Topics
Leakage Before the Split: How Pure Noise Scored 93% AccuracyData Engineering for MLTrain/Serve Skew: One Input Computed Two WaysWhy Production BreaksETL vs ELT: What a Model Loses When the Raw Rows Are Thrown AwayData Engineering for MLWrong Labels: How Many Can a Model Survive, and Can You Find Them?Data Engineering for MLRebalance, or Just Move the Threshold? Measured on Rare ClassesData Engineering for ML
1 of 25

More Cards Than Drawers

Let me start with a small picture.

Imagine a library card cabinet. Every book has a card, and every card must go into a drawer. If the cabinet has one drawer for every book, each card has its own place. But a big library may have thousands of books and only a few dozen drawers.

So the librarian uses a rule. Maybe she files each card by the first letter of the title. Now many cards share a drawer. The rule is quick and the cabinet stays small. But two very different books can end up side by side. A reader who opens the drawer cannot tell them apart without looking closer.

A flat illustration of a library. A woman sits at a tall wooden cabinet of many small drawers and files a card into an open drawer. Behind her, a man carries a stack of books past piles of more books on a table. Below the scene: when there are more cards than drawers, some cards must share a drawer.

Machine learning has the same problem with columns that hold names instead of amounts. A shop's product code is one. This shop has 4,646 of them. A model needs numbers, so each code must be turned into numbers somehow. You can give every code its own column. You can file codes into a fixed number of drawers. Or you can replace each code with one number learned from the past.

In this lesson I try all three on real data, and two more besides. I count how many codes share a drawer, how much memory each choice needs, and what happens to a code the model has never seen.

Where This Lesson Starts

This lesson uses the same shop, the same customers and the same question as the rest of the chapter. If any of that is new, please read what a feature is first. It sets up the data and the task, and it built six simple features that I reuse here as the starting point.

Those six features were recency, frequency, money, return share, tenure and products. They are all amounts: days, counts and pounds. None of them says WHICH products a customer likes. This lesson asks whether adding that, a column of product names, helps the model, and which way of turning names into numbers works best.

Two earlier lessons matter here. The lesson on point-in-time joins showed what happens when a feature sees past the cutoff. One of the encodings in this lesson did exactly that, by accident, and I only found it after the results came in.

The data engineering lesson on leakage before the split already measured how target encoding leaks when it is done on all the rows at once. I do not repeat that lesson here. This lesson is about scale: thousands of values, names that share a drawer, and names that only appear after training.

The Words You Need First

Please read this slide slowly if any word is new. Every slide after it uses these words.

A hand-drawn grid of eight cards, two per row, each a word and its meaning. Categorical column: a column of names, not amounts, such as a product code or a country. One-hot: one column per name, a 1 in the column of this row's name and 0 in all others. Hashing trick: a fixed rule turns each name into a drawer number, with one column per drawer. Collision: two different names land in the same drawer and look the same. Target encoding: replace a name with the share of its rows that bought. Out-of-fold: a row's share is worked out from other rows, never its own. Unseen value: a name at serving time that training never saw. Dense, sparse: dense stores every 0, sparse stores only the non-zero cells. Below: feature, cutoff, label, AP and seed mean what they meant in lessons 1 to 4.

Categorical column. A column that holds names, not amounts. A product code like 85123A is a name. Adding two codes together means nothing, so a model cannot use the code as it is.

One-hot encoding. One new column per name. For each row, the column of that row's name holds a 1 and every other column holds a 0. Like a light switch panel with one switch per name, and only one switch on.

Hashing trick. A fixed rule, called a hash function, turns each name into a number. That number is cut down to a fixed range, so it points at one of a fixed number of buckets, the drawers of the picture. There is one column per bucket.

Collision. Two different names that land in the same bucket. To the model, they then look like the same name.

Target encoding. Replace each name with the share of its rows whose label was 1, here the share of customers with that favourite product who bought again. The word "target" means the label.

Out-of-fold. The rows are split into parts called folds. A row's target encoding is worked out from the other folds only, so its own label is never inside its own number. scikit-learn calls this cross fitting: the encoder is fitted on some folds and used on the fold it did not see.

Unseen value. A name that arrives at serving time but never appeared in training.

Which Categorical Column, and Why

The shop's data has two name columns that could describe a customer: the products they bought, and their country. I had to turn them into one value per row, because a row in this chapter is one customer at one cutoff.

For the product, I chose each customer's favourite code: the stock code on the most purchase lines before the cutoff. Ties go to the code bought most recently, then to the smaller code as text. A customer whose only lines before the cutoff are returns gets the value "none". For the country, I used the country on the customer's last line, exactly as lesson 1 did. So "none" is one of the favourite values too. When I say there are 1,673 favourite codes in the train rows, that count includes "none"; it is the favourite on 311 test rows.

Why one favourite and not every product the customer ever bought? That second choice, which I call the bag, holds more information. But one value per row is the textbook shape of this question: one column with thousands of possible names. With one value per row, all the encodings get exactly the same information, so the race between them is fair. I still measured the bag's size, and I hashed it in two extra contestants, so you can see what the bigger column costs.

A hand-drawn sketch for customer 12349 at the cutoff 2011-07-01. A top box: favourite code 16156S, wrap pink fairy cakes, 2 lines. Arrows lead down to three boxes. One-hot: a 1 in column 39 of 1,673. Hashed: bucket 5 of 64, 69 of 256, 69 of 1,024, 1,093 of 4,096. Target encoding: 0.153. Below: the code is a favourite in 210 train rows; country Italy; bag 90 codes; label, did not buy in the 30 days after.

Here is one real customer, number 12349 at the cutoff 1 July 2011, the same customer lessons 1 and 4 used. Their favourite code is 16156S, wrapping paper with pink fairy cakes, on just 2 of their lines. They had bought 90 different codes. The same code is the favourite in 210 train rows, so the model has seen it many times. One-hot puts a 1 in column 39 of 1,673. Hashing puts it in bucket 69 of 1,024. Target encoding, at seed 0, turns it into 0.153.

Three Ways to Turn a Name Into Numbers

The three main encodings give the model very different shapes.

A page in three parts about what the model receives for one row. One-hot, 1,673 code columns: a short example stretch of small cells, with one filled, not the real column number; exactly one cell is 1 and every other cell is 0. Hashed into 2 to the power 10, 1,024 columns: the same kind of stretch with one filled, but the column is chosen by a rule and some codes share one. Target encoding, 1 column: a single box holding 0.153, a share of buyers learned from the label. Below: same code; only one of the three reads the label.

One-hot is wide. Here it made 1,673 code columns, one for every favourite code in the train rows, plus 40 country columns. Every row has a single 1 in the code part. Nothing is lost, but a code that never appeared in training has no column at all.

Hashing is also wide, but you choose the width. scikit-learn's FeatureHasher runs each name through a hash function called MurmurHash3. It turns any text into a big whole number, and the same text always gives the same number. The number is then cut down to the number of buckets with a remainder after division. So "code=16156S" always lands in bucket 69 of 1,024. The hasher never needs to see the training data first. It also has an answer for a code it has never seen: that code lands in some bucket too.

Target encoding is narrow: one column per name column. It is the only one of the three that reads the label. That makes it powerful, because the number already says something about buying. It also makes it dangerous, because a number built from labels can carry a row's own answer into training.

The idea of hashing features is older than any one paper. A well-known paper on it is "Feature Hashing for Large Scale Multitask Learning" by Weinberger, Dasgupta, Langford, Smola and Attenberg, at the ICML conference in 2009. Its new step was a second hash that gives each feature a plus or minus sign, so that collisions tend to cancel out on average. They used it to filter spam for hundreds of thousands of email users at once.

The Shop, the Task and the Split

The data is UCI Online Retail II, a public dataset under a CC BY 4.0 licence. It holds every sale of a UK online shop from 1 December 2009 to 9 December 2011. The shop mainly sells gift items, and many of its customers are wholesalers, people who buy to sell again. The chapter's fixed cleaning drops lines with no customer id and keeps returns as flagged rows.

The task is the chapter's fixed one. On the first of each month, for every customer seen before that day, will they buy in the next 30 days? Each pair of a customer and a cutoff is one row, and the label is 1 if they bought.

The rows are split by time. Train months, March 2010 to March 2011, give 44,521 rows; the models and every encoder learn from these. Valid months, April to June 2011, give 14,673 rows; choices like the smoothing and the number of buckets are made here. Test months, July to November 2011, give 26,851 rows; they only score, and they choose nothing.

Average precision (AP) asks: when the model's list of likely buyers is read from the top, how many of the names near the top really bought? A random list scores about the share of buyers, which averaged 0.196 over the five test months. Every AP in this lesson is worked out per test month and then averaged.

How the Lab Was Built

I wrote the lab's design into the docstring of scripts/labs/features/categorical_scale.py before it first ran. Before that I had looked only at the data's shape and at scikit-learn's own descriptions of the three encoders. I had also timed one model fit on random numbers of the one-hot shape, only to check the run would finish. No model had been trained on this task with any product column.

A flowchart. 44,521 train rows feed favourite code and country, which feed the six alone, and 13 contestants built on the six, which feed the same default model with 20 seeds, then valid months: pick smoothing and hash size, then test months: score once. Below: every encoder is fitted on the train rows only; the test months choose nothing.

The model is the chapter's usual one: scikit-learn's HistGradientBoostingClassifier with its default settings. It builds many small decision trees, each one fixing the mistakes of the trees before it. Only the inputs change from one contestant to the next.

A page in six labelled zones listing the inputs each model got on top of lesson 1's six. No code, 2 contestants: base, the six alone, and country, the six plus 40 country columns. One-hot, 1: 1,673 code columns plus 40 country columns. Hashing, 5: code and country hashed together into 64, 256, 1,024, 4,096 or 16,384 buckets. Target encoding, 3: 2 columns, cross-fitted on folds of rows, on folds of customers, or not at all. Frequency, 1: 2 columns, the share of train rows that hold this code and this country. The whole bag, hashed, 2: every code the customer ever bought, into 256 or 1,024 buckets. Below: only the inputs change; the model and its settings never do.

The frequency encoding replaces a name with the share of train rows that hold it. It is cheap and reads no label. I put it in the design as a cheap baseline. Its design had a flaw I only found after the results: it counted over all 13 train months. It turned out to be the biggest story in the lesson.

The design wrote down six guesses so they could be wrong in public. Here they are, scored.

  1. Country adds nothing. Right: 0.5452 with country and 0.5452 without.

  2. (An interval is a range of likely gaps; the slide How Sure Can I Be explains it.) Wrong twice. One-hot and the three bigger hash sizes did clear zero, just. And target encoding with the usual folds came in below the six.

How Many Codes Share a Bucket

Before any model, the lab counted the collisions. There are 1,673 different favourite codes in the train rows, and I hashed each one at five sizes.

An isometric drawing of five blocks in a row, one per number of buckets: 64, 256, 1,024, 4,096 and 16,384. Each block is as tall as the share of the 1,673 favourite codes that share a bucket with another code: 100.0 percent, 99.8 percent, 79.4 percent, 32.8 percent and 10.3 percent, so the blocks shrink from left to right. Below: at 16,384 buckets, 173 codes still share, on 5,997 of 44,521 train rows.

bucketscodes sharing a buckettrain rows affectedmost codes in one bucket
641,67344,52137
2561,66944,48517
1,0241,32936,5396
4,09654815,3824
16,3841735,997

The Lab's Report, Running

This is a real recording of the report script, cat_report.py, on the laptop where the lab ran. It trains nothing. It reads the stored results and checks every number this lesson uses.

A terminal recording of cat_report.py. It prints a table with one line per contestant: its columns, the megabytes of its dense matrix, its mean test AP over 20 seeds, and the 95 percent interval of its gap to the six. Then the collisions at each bucket size with the rows hit and the count expected by chance; three more intervals; then two sections headed after the results: early stopping on held-out customers, and frequency at the row's own cutoff, with the kept and changed favourites and their buy rates. The last line says 588 checks against the stored run and the raw invoices all agree.

The report rebuilds every row's favourite code from the raw invoices with its own, simpler code. From those it recounts the unseen rows, the bag, and customer 12349's values. It also has its own copy of MurmurHash3, written in plain Python. It checks that this copy puts every code in the same bucket as scikit-learn does, at every size. Then it recounts the collisions with it.

It also checks each mean against the 20 stored seeds, and each interval against its 1,000 stored resamples. It checks the student demo against the lab and the playground box's printout. Last, it checks that every number in this lesson appears in the lesson text. If anything disagrees, it stops with an error.

What Each Encoding Scored

Here is every contestant, scored on the five test months and averaged over 20 training seeds.

A dot chart with one row per contestant, from the six at the top to the bag hashed into 1,024 buckets at the bottom. Each row has 20 small dots, one per seed, and a short bar at their mean. A dashed vertical line marks the six alone. Most rows sit on or just right of the line; target encoding on folds of rows sits a little left of it; target encoding in-fold sits far left; frequency sits farthest left. Below: dashed line, the six alone, 0.5452; picked on the valid months over 20 seeds, plus hash 2 to the 10, 0.5475; lowest, plus frequency, 0.4378.

inputscolumnstest APROC-AUC
the six alone60.54520.798
+ country460.54520.799
+ one-hot1,7190.54700.800
+ hash, 64 buckets700.54630.799
+ hash, 256 buckets

How Sure Can I Be?

Gaps of a few thousandths need care. I checked two kinds of luck.

Luck in training. The model sets aside 10 percent of the train rows at random to decide when to stop adding trees. That is called early stopping, and the seed moves it. So every contestant was trained with 20 seeds, 0 to 19. The six alone ranged from 0.539 to 0.549 over the seeds, a spread several times larger than the best gain. One seed alone would have told almost any story.

Luck in which customers were tested. A bootstrap draws the test customers again at random, with repeats allowed, within each test month. I did that 1,000 times, with the same draws for every contestant, and each time worked out the 20-seed mean AP of each contestant minus the six. The middle 95 percent of those gaps is the 95% interval.

Thirteen rows, one per contestant against the six, each with its 95 percent interval written out and drawn as a bar against a dashed mark at zero. Country, -0.0013 to +0.0012. One-hot, +0.0002 to +0.0033. Hash 2 to the 6, -0.0004 to +0.0026. Hash 2 to the 8, -0.0005 to +0.0025. Hash 2 to the 10, +0.0008 to +0.0039. Hash 2 to the 12, +0.0006 to +0.0038. Hash 2 to the 14, +0.0002 to +0.0035. Target, rows, -0.0091 to -0.0036. Target, people, -0.0003 to +0.0030. Target, in-fold, -0.0558 to -0.0418, far off the left edge. Frequency, -0.1155 to -0.0992, far off the left edge. Bag 2 to the 8, -0.0043 to +0.0024. Bag 2 to the 10, -0.0045 to +0.0026. Below: a bar that crosses the dashed mark cannot be told apart from no gap.

Four gains stayed above zero: one-hot, +0.0002 to +0.0033, and hashing at 1,024, 4,096 and 16,384 buckets. Each is very close to zero at its low end, and one-hot's did not survive a later check. Country, the two small hashes, target encoding on folds of customers and both bag contestants cannot be told apart from the six. Three contestants were clearly worse: target encoding on folds of rows, target encoding with no folds, and frequency.

One-hot against 16,384 buckets: -0.0005 to +0.0004. Those two cannot be told apart. 16,384 buckets against 64: -0.0004 to +0.0020, also not clear.

A confound to keep in mind. The contestants that gained also had far more columns. One-hot had 1,719 against 6. So these numbers cannot say whether the code itself helped, or whether a model with more columns to split on simply found a little more. And a later check, two slides on, found the one-hot gain does not survive a change in how early stopping picks its rows.

What the Width Costs

The scores barely moved. The memory did not.

Two columns titled what the width costs, bytes for the 44,521 train rows as 64-bit numbers. The left column, sparse as the encoder gives it: one-hot 1.2 MB, hash 2 to the 12 1.2 MB, hash 2 to the 14 1.2 MB, target dense by nature. The right column, dense as the tree needs it: one-hot 612.3 MB, hash 2 to the 12 502.9 MB with 1.46 GB if empty buckets were kept, hash 2 to the 14 579.1 MB with 5.84 GB if empty buckets were kept, target 2.8 MB.

The encoders hand back a sparse table. Each row has only two cells that are not zero, one for the code and one for the country. So the sparse table is about 1.2 MB at every width. Width costs almost nothing when you store only the ones.

The model is a different matter. This kind of tree model in scikit-learn needs a dense table, every cell stored. I checked: given a sparse table, it stops with an error that says dense data is required. One-hot then needs 612.3 MB for the train rows alone. Hashing into 16,384 buckets would need 5.84 GB if the empty buckets were kept; dropping them brought it to 579.1 MB. Target encoding needs 2.8 MB, the same as the six plus two columns. A MB is a million bytes and a GB is a thousand MB.

So the cost of a wide encoding depends on the model. A linear model, which gives each column a weight and adds them up, can read the sparse table as it is. I did not test one. For the tree model here, the width is paid in full.

Why not let the tree model handle the codes itself? HistGradientBoostingClassifier has its own support for categorical columns. I tried it on the favourite codes. It stopped with an error: "Categorical feature at index 0 is expected to have a cardinality <= 255 but actually has a cardinality of 1673." Its documentation says each categorical column may have at most max_bins values, and max_bins can be no larger than 255.

The Bag Is the Bigger Column

The favourite is one code per row. The bag, every code the customer ever bought, is much bigger.

Two panels. Favourite: 1 code per row, with 1,673 different codes in the train rows. Bag: 32 codes per row as the median, with 4,060 different codes and 2,427,948 non-zero cells. Below: as one dense table the bag would be 1.45 GB; stored sparse, about 29.3 MB.

The train rows hold 4,060 different codes in their bags. The middle customer row has 32 codes, and the mean is 54.5, because some wholesalers buy hundreds of products. One-hot over the bag, often called multi-hot because a row can have many ones, would have 2,427,948 non-zero cells in the train rows. Stored dense, it would be 1.45 GB. Stored sparse it would be about 29.3 MB; that figure is worked out from the cell count, not measured from a built table.

I hashed the bag into 256 and 1,024 buckets. At 1,024 buckets, 3,980 of the 4,060 codes share a bucket. Neither bag contestant could be told apart from the six: 0.5443 and 0.5442 against 0.5452.

The bag also has more unseen codes. 10,806 test rows hold at least one code that no train row's bag held. That is 5.7 percent of all the codes in test bags. New products arrive every month, and a customer who buys a lot will buy some of them.

So the bigger column was not the stronger column here. One possible reason, which I did not test: the six already know how much and how often a customer buys, and which products they buy says little extra about whether they come back within a month.

A Code the Model Has Never Seen

At serving time, some customers have a favourite that no train row had. The lab counted them: 1,018 test rows, 3.8 percent. Of those, 289 hold a code the shop had not sold at all before 1 March 2011. The rest are older codes that simply were nobody's favourite in training. Five test rows also had a country that no train row had.

Three panels about what each encoding does with a new code, over the 1,018 test rows, 3.8 percent, whose favourite no train row had. One-hot: all 0; every code column is zero, checked on all of those rows. Hashed: a bucket; at 2 to the 14, 86 land in a train code's bucket, and almost all the rest in an empty one. Target: 0.234, the share of buyers over all train rows. Below: frequency encoding gives them 0; 289 of the rows hold a code the shop had not sold before March 2011.

Each encoding has its own answer, and the lab checked each one on the real test matrices.

One-hot with handle_unknown="ignore" gives an unseen code a block of all zeros. The row then looks like a code the model never learned anything about.

Hashing has no idea what "unseen" means. The code lands in a bucket like any other. At 64 and 256 buckets, all 1,018 rows landed in a bucket a train code used, so they borrowed some other code's meaning. At 16,384 buckets only 86 did. Of the other 932, almost all landed in an empty bucket, which the model ignores; 8 landed in a bucket a country name uses.

Target encoding gives an unseen code the overall share of buyers in training, 0.234. That is scikit-learn's documented rule: the value is called target_mean_.

Frequency gives it 0, the share of train rows that hold it.

These rows matter more than their number suggests. 26.4 percent of them bought, against 19.5 percent of the other test rows. One possible reason, which I did not check: a customer whose favourite is new may be a customer who is buying a lot right now.

How the Encodings Did on New Codes

I scored the 1,018 unseen rows on their own. They are spread over the five test months, from 133 rows in July to 294 in November, so each month's AP rests on few buyers. Treat these numbers as rough.

A bar chart of AP on the 1,018 unseen rows, per test month, mean of 20 seeds, for seven sets of inputs. The six, one-hot, hash 2 to the 10, hash 2 to the 14 and target with folds of customers all sit near 0.54. Target with folds of rows is a little lower, near 0.49. Frequency is far lower, near 0.31. Below: six 0.538; target with row folds 0.494; frequency 0.315; the rest sit within 0.01 of the six.

On these rows the six alone scored 0.538. One-hot scored 0.540 and hashing into 1,024 buckets 0.545. Their bootstrap intervals against the six, -0.0031 to +0.0071 and -0.0034 to +0.0177, both cross zero. So for new codes, giving them all zeros or a random bucket made no difference I could detect.

Two encodings did clearly worse here. Target encoding on folds of rows scored 0.494, an interval of -0.0751 to -0.0156. Frequency scored 0.315, an interval of -0.2696 to -0.1619.

Why would they do worse on new codes? Both give every new code one fixed value: 0.234 for target encoding, 0 for frequency. For frequency, a 0 never appears in training, so the model has never seen such a row. The next slides show that both encodings also learned something misleading in training.

Target Encoding: Folds of Rows Were Not Enough

Target encoding with the usual folds lost 0.0064. That was the first surprise, because those folds exist to stop a row seeing its own label. They did stop that. They did not stop something else.

A sequence diagram with four lifelines: fold maker, encoder, one row and model. Step 1, the fold maker tells the encoder that the other folds hold this customer's other months. Step 2, the encoder gives the row the code's share of buyers, from those months. Step 3, the row tells the model: for a rare code, the share is this person's. Step 4, the model can learn who, not what. A large 13 in the corner is labelled train months one customer can fill. Below: 655 favourite codes belong to one customer only, on 2,995 train rows; folds of customers keep all of a person's months together.

In this chapter, one customer appears once per month. One customer can fill up to 13 train rows. Folds of rows put those rows in different folds at random. So when a row's code is encoded "from the other folds", the other folds hold the same customer's other months, with their labels.

For a popular code that hardly matters. But 655 favourite codes belong to a single customer, on 2,995 train rows. For those, "the share of buyers with this code" is simply "how often THIS customer bought in their other months", some of them later months. That number knows about the customer's future.

So I had declared, before the run, a second version: the same encoder with folds of customers. All of one person's rows go into one fold, so a row is never encoded from its own person's labels.

A hand-drawn flow of four boxes joined by arrows, titled where the folds come from decides it, mean test AP of 20 seeds. No folds, fit then transform the same rows: 0.4963. Folds of rows, the usual default: 0.5387. Folds of customers: 0.5466. The six alone, for reference: 0.5452. Below: customers against rows, +0.0055 to +0.0100, all 5 months.

Folds of customers scored 0.5466, against 0.5387 for folds of rows. The interval for that difference was +0.0055 to +0.0100, and folds of customers won all 5 test months. The two contestants differ only in their folds, so the folds are what made the difference here. Against the six, folds of customers could not be told apart: -0.0003 to +0.0030.

With no folds at all, fitting the encoder and then transforming the same train rows, the score fell to 0.4963. The data engineering lesson on leakage before the split measured this leak on noise, so I will not repeat it here. scikit-learn's documentation warns about it directly: does not equal , because only cross-fits.

Choosing the Smoothing

Target encoding needs one more choice: smoothing. A code with only two rows has a share of buyers that is mostly luck. Smoothing pulls such a share toward the overall share. A bigger smoothing number pulls harder. scikit-learn also offers "auto", which picks the amount from the data.

A dot chart of valid AP at seed 0 for target encoding with folds of rows, at five smoothing values: auto, 1, 10, 100 and 1000. The dots for auto and 1 sit highest, near 0.524, and the other three sit near 0.519. Below: picked over 20 seeds, auto 1, 1 5, 10 5, 100 7, 1000 2.

The design said to choose the smoothing on the valid months, at seed 0, using target encoding with folds of rows. Smoothing 1 won there with a valid AP of 0.5245, just above "auto" at 0.5237. All three target encoding contestants then used smoothing 1.

Then I looked at the other 19 seeds. The valid months picked "auto" once, 1 five times, 10 five times, 100 seven times and 1000 twice. So the choice was close to a coin toss. The valid scores of the five settings were all within about 0.006 of each other, and the random part of training was enough to change the order.

A confound to name: smoothing 1 is weak smoothing, so it leaves rare codes close to their own customer's buy rate. That may have made the folds-of-rows problem worse than a stronger smoothing would. I chose it by the rule written in advance and did not rerun with another.

The same thing happened with the hash size. At seed 0 the valid months picked 4,096 buckets. On the mean of all 20 seeds they picked 1,024. Both gave nearly the same test AP, 0.5474 and 0.5475.

Why Frequency Lost: Two Checks After the Results

Frequency encoding lost 0.1074, and it never reads the label. That made no sense to me at first. Everything on this slide was designed after I had seen the main results, and each check's design was written into the lab's docstring before it ran.

The first idea was wrong. I guessed the model was memorising customers, and that its early stopping could not see it, because early stopping holds out random rows and the same customers sit in those rows. So I trained again with early stopping on held-out CUSTOMERS instead: 10 percent of the train customers, none of whom the model trained on.

A dot chart of mean test AP over 20 seeds for seven sets of inputs, each with two dots: early stopping on random rows held out, and on customers held out. For every set the two dots sit almost on top of each other: the six near 0.546, one-hot and hash 2 to the 12 near 0.547, target with folds of rows near 0.539, target with folds of customers near 0.546, target in-fold near 0.50, frequency near 0.44. Below: frequency, 0.4378 then 0.4405; target, rows, 0.5387 then 0.5392.

It changed almost nothing. Frequency went from 0.4378 to 0.4405, and target encoding on folds of rows from 0.5387 to 0.5392. So the way early stopping picked its rows was not the cause. This check did shake one result, though: one-hot's interval against the six became -0.0006 to +0.0024, which crosses zero, while 4,096 buckets stayed above it, +0.0003 to +0.0031. I would not lean on the one-hot gain.

The second idea fits the data, but it is not proved. The frequency of a code was counted over the rows of all 13 train months. So a row from March 2010 got a number that included rows from later months. Think about a code only one customer favours. A favourite only changes when the customer buys something. If they buy nothing new, the same favourite sits there month after month, and its count keeps growing. So a big count partly means "this person went on not buying". That is a fact about the future, hidden inside a plain count.

A page of three large numbers. 0.4378: frequency counted over all 13 train months. 0.5433: frequency counted at the row's own month only. 0.5452: the six alone. Below them: rows whose code only one customer favours; when the favourite stayed the same next month, 6.9 percent bought, on 2,330 rows; when it changed, 98.9 percent bought, on 355 rows. Below: whether the favourite changed next month nearly says whether they bought.

To test it, I counted frequency at each row's own month only: the share of that month's rows holding the code. It reads nothing after the cutoff, and a live system could compute it on the day. It scored 0.5433. Against the six, -0.0048 to +0.0011: the big loss was gone.

The Lab's Code, Piece by Piece

The lab is one file, scripts/labs/features/categorical_scale.py. It imports lesson 1's feature code for the six, so they are built exactly as before. Its base contestant must reproduce lesson 1's score of 0.5450 at seed 0, to twelve decimal places, or the run stops. It did.

favourites builds the column. For each cutoff it takes the events before the cutoff with one searchsorted, keeps the purchase lines, and counts lines per customer and code with groupby. It sorts by count, then by the latest time, then by the code, and keeps the first row per customer. The same pass builds each customer's bag as a sorted tuple of codes.

encode_unsupervised builds every contestant that does not read the label: OneHotEncoder(handle_unknown="ignore"), FeatureHasher(n_features=2**k, input_type="string", alternate_sign=False), and the frequency map. I turned off the hasher's alternating sign, which is on by default, so that a bucket is a plain count and two codes in one bucket really do look the same. The hasher gets the tokens "code=16156S" and "country=Italy" in one shared space, so a code can even collide with a country.

with_six stacks the six onto the encoded columns. For sparse encoders it first records the sparse table's bytes, then drops columns that are zero in every train row, then builds the dense table and records its bytes.

handles the three target versions. Folds of rows use . Folds of customers use with the customer id as the group. Since scikit-learn 1.9, accepts such a splitter as its . The no-folds version calls and then on the same rows.

Try It Yourself

The full lab trains several hundred models. I wrote a small demo that does the heart of it: it builds each customer's favourite code, then trains four models at seed 0 and prints their test AP.

A page in four labelled zones, headed cat_demo.py, designed before it ran. The data: the chapter's shop data through task.py, and lesson 1's six. The column: each customer's favourite code and their country. The models: four, seed 0; the six; plus hash 2 to the 10; plus target on folds of rows; plus target on folds of customers. The check: every test month must match the lab's seed 0. Below: it printed 0.5450 for the six and 0.5457 for target with folds of customers, as the lab did at seed 0.

I wrote the demo's design into its docstring after the lab had run and before the demo first ran. My first version also trained the one-hot model. It was the slowest part by far, so I took that model out; hashing shows the same wide shape.

A real screenshot of VS Code with cat_demo.py open at the top of the file, showing its docstring: what it needs, how to run it, and the design written before it first ran.

Before you run this lab. You need Python 3 with pandas, pyarrow and scikit-learn: pip install pandas pyarrow scikit-learn openpyxl. Use scikit-learn 1.9 or newer, because the demo hands TargetEncoder a list of folds. First run python fetch_data.py from the scripts/labs/features folder. It downloads the shop data once, about 46 MB, and writes one cleaned file. Then run the demo. It needs no GPU, and it should take about one to three minutes on a laptop. I ran it with scikit-learn 1.9.1 and pandas 3.0.6 on a Mac. These libraries run on Windows and Linux too, but I have not checked the numbers there. Give it a file name, python cat_demo.py out.json, and it also saves every number. That is how results/cat-demo.json was made.

"""One-hot, hashing or target encoding for a product code? The lab, small.

Lesson 7 of 'Features and Feature Stores'. It needs Python 3 with pandas,
pyarrow and scikit-learn, and the shop data: run fetch_data.py once first
(it needs openpyxl too, and downloads UCI Online Retail II, about 46 MB).
Then, from the folder above this one or from this folder:
    python examples/cat_demo.py            # print the table
    python examples/cat_demo.py out.json   # and save every number
It prints no timings.

Design, written 2026-10-01 after the lab (categorical_scale.py) had run
and before this file first ran:
  Task and data: task.py, exactly as in the lab. The six features are
  lesson 1's, built by lesson 1's own code (what_a_feature_is.py).
  The categorical: each customer's favourite stock code before the
  cutoff (the code on the most purchase lines; ties go to the code
  bought most recently, then the smaller code), and their country.
  Four models, the default HistGradientBoostingClassifier, seed 0:
  the six alone; plus hashing into 2^10 buckets; plus target encoding
  with row folds; plus target encoding with folds by customer. (The
  lab's one-hot model is left out: at 1,719 columns it is the slowest
  fit, and hashing shows the same wide shape.) It keeps the hashed columns that are empty in training
  (the lab drops them; it checked this changes no prediction).
  It must agree with the lab's seed-0 test AP on every test cutoff,
  to 1e-9. cat_report.py checks this.

Author: Roni Das
Created: 2026-10-01
"""
import json
import sys
from pathlib import Path

import numpy as np
import pandas as pd
from sklearn.ensemble import HistGradientBoostingClassifier as HGB
from sklearn.feature_extraction import FeatureHasher
from sklearn.metrics import average_precision_score
from sklearn.model_selection import GroupKFold, StratifiedKFold
from sklearn.preprocessing import TargetEncoder

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import task  # noqa: E402
from what_a_feature_is import HAND_COLS, joined  # noqa: E402


def favourite(ev, lab, cutoffs):
    """Lesson 1's six, plus fav_code, per (customer, cutoff)."""
    d = joined(ev, lab, cutoffs)
    favs = []
    for t in cutoffs:
        buys = ev[(ev["ts"] < t) & ~ev["is_return"]]
        g = buys.groupby(["customer_id", "stock_code"])["ts"]
        c = g.agg(["size", "max"]).reset_index()
        c = c.sort_values(["customer_id", "size", "max", "stock_code"],
                          ascending=[True, False, False, True])
        f = c.drop_duplicates("customer_id")[["customer_id", "stock_code"]]
        favs.append(f.assign(cutoff=t))
    f = pd.concat(favs).rename(columns={"stock_code": "fav_code"})
    d = d.merge(f, on=["customer_id", "cutoff"], how="left")
    d["fav_code"] = d["fav_code"].fillna("none")
    return d


def test_ap(x_tr, y_tr, x_te, d_te):
    m = HGB(random_state=0).fit(x_tr, y_tr)
    p = m.predict_proba(x_te)[:, 1]
    d = d_te.assign(p=p)
    return [average_precision_score(g["label"], g["p"])
            for _, g in d.groupby("cutoff")]


ev = task.load_events()
lab_tr, _, lab_te = task.splits(ev)
tr = favourite(ev, lab_tr, task.TRAIN_CUTOFFS)
te = favourite(ev, lab_te, task.TEST_CUTOFFS)
y = tr["label"].to_numpy()
seen = set(tr["fav_code"])
unseen = int((~te["fav_code"].isin(seen)).sum())
print(f"rows: train {len(tr):,}, test {len(te):,}")
print(f"favourite codes in training: {len(seen):,}")
print(f"test rows with an unseen favourite: {unseen:,}")

cols = ["fav_code", "country"]
six_tr, six_te = (d[HAND_COLS].to_numpy(float) for d in (tr, te))
h = FeatureHasher(2**10, input_type="string", alternate_sign=False)
tok = lambda d: [[f"code={c}", f"country={k}"]  # noqa: E731
                 for c, k in zip(d["fav_code"], d["country"])]
bucket = {c: h.transform([[f"code={c}"]]).indices[0] for c in seen}
share = pd.Series(bucket).value_counts()
alone = sum(share[b] == 1 for b in bucket.values())
print(f"2^10 buckets: {len(seen) - alone:,} codes share a bucket")

arms = {"six alone": (six_tr, six_te)}
arms["+ hash 2^10"] = tuple(np.hstack([s, h.transform(tok(d)).toarray()])
                            for s, d in ((six_tr, tr), (six_te, te)))
folds = {"+ target, rows": StratifiedKFold(5, shuffle=True, random_state=0),
         "+ target, people": list(GroupKFold(5, shuffle=True, random_state=0)
                                  .split(tr, y, tr["customer_id"]))}
smooth = 1.0  # the lab picked this on the valid months, at seed 0
for name, cv in folds.items():
    enc = TargetEncoder(target_type="binary", smooth=smooth, cv=cv)
    e_tr = enc.fit_transform(tr[cols], y)
    arms[name] = (np.hstack([six_tr, e_tr]),
                  np.hstack([six_te, enc.transform(te[cols])]))

print(f"{'inputs':18s} {'columns':>7s} {'test AP':>8s}")
results = {}
for name, (x_tr, x_te) in arms.items():
    aps = test_ap(x_tr, y, x_te, te)
    results[name] = {"columns": x_tr.shape[1], "test": list(map(float, aps))}
    print(f"{name:18s} {x_tr.shape[1]:7d} {np.mean(aps):8.4f}")
if len(sys.argv) > 1:
    json.dump({"unseen": unseen, "fav_codes": len(seen),
               "shared_2_10": int(len(seen) - alone), "smooth": str(smooth),
               "arms": results}, open(sys.argv[1], "w"), indent=1)

Count the Collisions Yourself

This box holds the real 1,673 favourite codes from the train rows and how many train rows hold each. It also holds MurmurHash3, the hash scikit-learn's FeatureHasher uses, written in plain Python, so it runs in your browser. The report checked that it puts every code in the same bucket as scikit-learn, at all five sizes.

Press Run to see how many codes share a bucket at 1,024 buckets, how many train rows that touches, and what pure chance predicts. Then change BUCKETS to 2 ** 6 or 2 ** 14 and run again. Last, change MINE to any code in the list, and see which codes share its drawer.

At the bottom it also prints each contestant's real mean test AP over 20 seeds, so you can set the collisions next to what they cost, or did not cost, in score.

Common Mistakes, and When Each Choice Helps

Counting over the whole training table. Frequency encoding, target encoding and any other count of a name must use only rows from before each row's cutoff, or the count carries the future in. Here that cost 0.1074 in AP, on an encoding that never reads the label.

Folds of rows when one person has many rows. If the same customer, user or device appears in many rows, fold by that person. Here folds of rows lost 0.0064 and folds of customers did not.

Fitting and transforming the same rows. With scikit-learn's TargetEncoder, call fit_transform on the training rows, and pass cv= a split by person, such as GroupKFold by customer, because the default row folds lost 0.0064 here. Use transform on new rows. Never fit then transform on the training rows.

Trusting a big hash to have no collisions. Even 16,384 buckets for 1,673 codes left 173 codes sharing. Count them, it is cheap.

Forgetting the unseen rows. Score them on their own. Here they were 3.8 percent of rows, but they bought more often than the rest.

When hashing helps most. When names keep arriving, when memory is tight and the model can read sparse tables, and when a few collisions are an acceptable price. My guess, not tested here, is that it shines most with a linear model.

When one-hot is enough. When the names are few, or settled, or the model reads sparse tables, as a linear model does.

When target encoding helps. When there are many names, a tree model, and enough rows per name, and only with folds that respect both time and the person.

What This Lab Cannot Tell You

Two columns titled what this lab shows, and what it cannot. Shows: how 13 contestants built on lesson 1's six did with one tree model, on one shop; how many codes collide at five hash sizes; that folds of rows let a customer's own labels in. Cannot show: a linear model, which can use sparse columns directly; other columns, like a product's description; a clean timing on a quiet machine.

One shop, one task, one model. A tree model with default settings, on a 30-day buying question. A linear model reads sparse tables natively and weighs every column on its own; it might gain more from one-hot or hashing. I did not test one.

One choice of column. I encoded each customer's favourite code. A description of the product, the shop's own product groups, or the bag with a richer model could behave differently. A later lesson in this chapter is planned to look at descriptions.

Folds of customers are still not point-in-time. They keep a person's own labels out, but a row from March 2010 is still encoded using other customers' labels from later months. A strictly time-respecting target encoding would use only rows from before each cutoff. I did not build one.

Two checks were added after the results. Both are labelled on the frequency slide, and their designs were written down before they ran. The first one did not support my idea; the second did.

No timings. The laptop was busy with another lab, so I report column counts and bytes only.

What to Do on Monday

A hand-drawn grid of six cards, titled six steps. 1, count first: how many values, how many rows each, how many unseen. 2, try hashing: count the collisions first; at 2 to the 14, almost 9 codes in 10 were alone. 3, fold by customer: if one customer has many rows, keep them in one fold. 4, never in-fold: fit then transform the same rows leaks the label. 5, watch new values: score the unseen rows on their own. 6, measure the gain: against the features you already have, with seeds. Below: a big column is not a strong column; measure it like one.

These are the steps I would take the next time someone hands me a column with thousands of names.

  1. Count first. How many names, how many rows hold each, how many arrive after training. Here 655 codes belonged to one customer only. Had I looked at that first, it would have warned me about the target encoding result.

  2. Try hashing, and count its collisions. It needs no fitting and has an answer for every new name. Pick the size on valid months.

  3. Fold by the person, and stay before the cutoff. For any encoding that counts or reads labels.

  4. Never fit and transform the same rows with a target encoder.

  5. Score unseen rows on their own.

  6. Measure the gain against the features you already have, with 20 seeds and an interval. Here the size picked on the valid months gained +0.0024.

A closing card titled small gains, big traps. Three numbers in large type with a line under each: +0.0024, hash 2 to the 10, picked on the valid months, over the six; -0.0064, target encoding with the usual folds; -0.1074, frequency encoding. Below: mean test AP of 20 seeds, minus the six alone.

The one idea to keep: on this shop, the way a product column was encoded mattered less for what it could add than for what it could break. A plain count over the whole training table broke the model; one that stayed inside each month did not.

Knowledge Check

Knowledge Check

4 questions - Score 80% to pass

Q1

Frequency encoding never reads the label. Why did it still lose so much here?

Q2

Target encoding with folds of rows lost to the six, but folds of customers did not. What changed between them?

Q3

At 16,384 buckets there were almost ten buckets for every favourite code. What did the lab find?

Q4

One-hot needed 612.3 MB as a dense table but only 1.2 MB as a sparse one. Why the difference?

Dense and sparse. A dense table stores every cell, zeros included. A sparse table stores only the cells that are not zero, plus their positions.

Feature, cutoff, label, average precision (AP), ROC-AUC and seed mean what they meant in lessons 1 to 4.

No favourite-code contestant beats the six by an interval clear of zero, and target encoding is the best of them.
  • The smallest hash is the worst hash, and the two largest land with one-hot. Wrong. One-hot and 16,384 buckets could not be told apart, but the smallest hash was not the worst: 256 buckets scored 0.5462, a hair below 64 buckets at 0.5463. And 64 buckets did not clearly lose to 16,384.

  • Target encoding without cross fitting scores below target encoding with it. Right, by far.

  • The bag adds more than any favourite contestant. Wrong. Neither bag contestant could be told apart from the six.

  • Unseen favourites are rare, under 10 percent of test rows. Right: 3.8 percent.

  • 3

    With 64 buckets, every code shares, and one bucket holds 37 different codes. With 1,024 buckets, 79.4 percent of codes still share. Even with 16,384 buckets, almost ten buckets for every code, 173 codes share, and they sit on 5,997 train rows, 13.5 percent of them. Popular codes sit on many rows, so a single collision can touch thousands of rows.

    That last point surprised me. Ten times more drawers than cards still leaves some cards together. The reason is the same as the famous birthday puzzle: in a room of only 23 people, two of them more likely than not share a birthday. Pairs add up much faster than people expect.

    A chart of how many of the 1,673 codes sit alone in their bucket, against the number of buckets from 2 to the power 6 up to 2 to the power 14. A dashed line shows what a perfectly random hash would give, and dots show what was measured; the dots sit on the line at every size, rising from 0 to about 1,500. Below: alone, measured against random: at 2 to the 10, 344 and 327; at 2 to the 12, 1,125 and 1,112; at 2 to the 14, 1,500 and 1,511.

    Is the hash doing anything strange? No. Suppose a hash spread codes perfectly at random. Then a short formula gives the expected number of codes alone in their bucket. It is the number of codes, times the chance that no other code picks the same bucket.

    The measured counts sit close to it at every size: 344 against 327 at 1,024 buckets, 1,500 against 1,511 at 16,384. Here a code counts as alone when no other code shares its bucket. The 40 country names share the same space, but they are left out of this count, as they are in the formula. So the collisions are not a bug in the hasher. They are simply what chance does.

    262
    0.5462
    0.799
    + hash, 1,024 buckets8480.54750.800
    + hash, 4,096 buckets1,4120.54740.800
    + hash, 16,384 buckets1,6260.54700.800
    + target, folds of rows80.53870.795
    + target, folds of customers80.54660.799
    + target, no folds80.49630.779
    + frequency80.43780.770
    + bag, 256 buckets2620.54430.798
    + bag, 1,024 buckets1,0020.54420.799

    Hashing into 1,024 buckets scored +0.0024 above the six. I do not call it the best because it tops this table: 13 test scores this close will always have a top one. It is the size the valid months picked on the mean of 20 seeds. Seed 0 alone would have picked 4,096 buckets, +0.0006 to +0.0038 against the six. That gap of +0.0024 is worked out before rounding, so it can differ from the rounded table by one in the last place. One-hot added +0.0018. These are small. On this task a product column barely moved the score, whichever way it was encoded.

    The losses were larger than the gains. Target encoding on folds of rows lost 0.0064. Frequency encoding lost 0.1074, which is a lot for two extra columns.

    The "columns" are the ones the model actually got. For hashing, a bucket that no train row uses is a column of zeros, and a tree can never split on a column that never changes. So the lab drops those columns before training. It checked that this changes no prediction at all, at 1,024 buckets and seed 0. That is why 16,384 buckets gave 1,626 columns and not 16,390.

    fit(X, y).transform(X)
    fit_transform(X, y)
    fit_transform

    That result is consistent with my idea, but it changed two things at once. It dropped the other months, the earlier ones as well as the later ones. And test rows now got counts from their own month, instead of counts frozen from the train months. Either change could be the one that mattered. A past-only running count, using only months before each row, would separate them. I did not build it.

    I also counted the link directly. Among rows whose code only one customer favours, when the favourite stayed the same the next month, 6.9 percent of those rows were buyers. When it changed, 98.9 percent were. This one is close to built in: a favourite can only change if the customer buys, and the next month mostly falls inside the 30-day label window. So whether the favourite changed next month nearly says whether they bought, and a count over later months carries some of that.

    If that is the cause, it is the point-in-time leak of lesson 2 in a disguise. Nobody joined a future table. The encoder simply counted over the whole training table at once, and the whole table includes the future of every early row.

    target_encode
    StratifiedKFold(5, shuffle=True, random_state=seed)
    GroupKFold(5, shuffle=True, random_state=seed)
    TargetEncoder
    cv
    fit
    transform

    mean20_bootstrap redraws customers within each test month, the same draws for every contestant. To keep 1,000 draws affordable it computes AP from the redraw counts as weights, with a short function borrowed from lesson 3's lab. It checks that function against scikit-learn's average_precision_score on the first draws before trusting it.

    This is a real run in VS Code's terminal: python cat_demo.py, run inside the examples folder.

    A real screenshot of VS Code's terminal after running python cat_demo.py inside the examples folder. It prints the train and test row counts, the number of favourite codes in training, the test rows with an unseen favourite, how many codes share a bucket at 2 to the 10, and then the columns and test AP of four sets of inputs: the six alone, plus hash 2 to the 10, plus target with folds of rows, and plus target with folds of customers.

    When I ran it, every test month matched the lab's seed 0 within one billionth, and the report checks this from the stored files. Notice what seed 0 shows: the six scored 0.5450 and the hash 0.5445, so at this one seed hashing came out BELOW the six. Over 20 seeds it came out above. One seed is one draw, and that is why the lab never trusts one. The demo also keeps the empty buckets, so its hash model has 1,030 columns where the lab's had 848; the lab checked that dropping them changes no prediction.

    Four cards with logos, titled what ran where. pandas: the invoices, each favourite code and each bag. scikit-learn: OneHotEncoder, FeatureHasher, TargetEncoder and the model. NumPy: the dense matrices, their bytes, and the bootstrap. Python: the lab and the report; the box needs only Python. Below: the model's own category support refused, at most 255 values, and the codes have 1,673.

    Everything here runs on a laptop CPU with no paid service. I give no timings in this lesson. The laptop was shared with another lab the whole time, so any seconds would mostly measure that other program.