{"course":{"slug":"machine-learning-foundations","title":"Machine Learning, Foundations","url":"https://addaly.com/learn/machine-learning-foundations","level":"intermediate","track":"start"},"lesson":{"slug":"categorical-encoding","title":"Turning categories into numbers without lying about them","url":"https://addaly.com/learn/machine-learning-foundations/categorical-encoding","position":22,"of":92,"minutes":9,"answer":"One-hot is the safe default because it asserts nothing about order or distance; target encoding is powerful and leaks unless computed out-of-fold and smoothed towards the base rate.","keyIdea":"One-hot is the safe default because it asserts nothing about order or distance; target encoding is powerful and leaks unless computed out-of-fold and smoothed towards the base rate."},"body":{"format":"markdown","text":"## The mistake that gets made first\n\nGiven a `city` column, the obvious move is to map cities to integers: Mumbai 0, Delhi 1, Chennai 2, Kolkata 3.\n\nA linear model now believes Chennai is twice Delhi and that Mumbai and Kolkata are three units apart. Those statements are meaningless, but the model cannot know that; it will fit a coefficient as though the ordering were real. This is called label encoding, it is the default in a lot of tutorial code, and it is wrong for any unordered category fed to a linear or distance-based model.\n\nIt is *not* wrong for trees, which is the source of much of the confusion. A tree can split \"city in {0, 3}\" versus \"city in {1, 2}\" and recover an arbitrary grouping given enough splits. Label encoding for LightGBM is common practice; label encoding for logistic regression is a bug.\n\n## One-hot: the safe default\n\nOne column per category, 1 in the column for this row's value and 0 elsewhere.\n\n```python\nfrom sklearn.preprocessing import OneHotEncoder\nenc = OneHotEncoder(handle_unknown=\"ignore\", sparse_output=True,\n                    min_frequency=20)\n```\n\nThree arguments carry real weight.\n\n**`handle_unknown=\"ignore\"`** decides what happens when production sends a category that was not in the training data. Without it, the transform raises and your service returns an error. With it, that row gets all zeros across the block and a prediction still comes out. This is a genuine choice with a downside (silently encoding an unknown city as \"none of the above\" may be exactly wrong for your problem) but a crash at 2am is worse, and the alternative is at least deliberate.\n\n**`min_frequency=20`** folds every category with fewer than 20 rows into a single \"infrequent\" column. This is doing real work: a category with three examples cannot support a reliable coefficient, and giving it one invites the model to memorise those three rows.\n\n**`sparse_output=True`** matters at scale. One-hot on 5,000 product codes gives 5,000 columns of which one is nonzero per row. Dense, a million rows would be 40 GB. Sparse, it is a few hundred megabytes. Linear models and SGD handle sparse matrices natively; some other estimators do not, and that is often the real reason to prefer another encoding.\n\n## Ordinal, where order is real\n\nWhen the categories have an order (\"never, rarely, sometimes, often, always\", or T-shirt sizes, or education level) integers are appropriate, but state the order explicitly rather than letting the encoder guess alphabetically:\n\n```python\nfrom sklearn.preprocessing import OrdinalEncoder\nOrdinalEncoder(categories=[[\"never\", \"rarely\", \"sometimes\", \"often\", \"always\"]])\n```\n\nRemember what you have asserted: not just the order, but that the gaps are equal. \"Never\" to \"rarely\" gets the same numeric distance as \"often\" to \"always\". Sometimes that is fine. When it is not, one-hot instead and let the model learn each level's effect separately.\n\n## Target encoding, and the trap inside it\n\nReplace each category with the mean of the target for that category. Mumbai becomes 0.043 because 4.3% of Mumbai rows defaulted.\n\nThis is powerful: one column instead of 5,000, and it carries exactly the information the model wants. It also leaks, in a way that produces spectacular validation scores and a model that fails in production.\n\nThe mechanism: a category with one row gets encoded as that row's own target. The feature now *contains the answer* for that row. The model learns to trust it completely, and on unseen data where the encoding comes from other rows, that trust is unjustified.\n\nThe fixes are standard and non-optional:\n\n1. **Compute the encoding out-of-fold.** For each training row, use the target mean computed from the *other* folds only. scikit-learn's `TargetEncoder` does this internally with cross-fitting.\n2. **Smooth towards the global mean.** A category with 4 rows should be pulled most of the way to the overall base rate; one with 40,000 rows should barely move. `smooth=\"auto\"` handles it.\n\n```python\nfrom sklearn.preprocessing import TargetEncoder\nTargetEncoder(target_type=\"binary\", smooth=\"auto\", cv=5)\n```\n\nEven with both, treat a target-encoded model's validation score with a little suspicion, and compare it against the same model with one-hot encoding before believing a large gain.\n\n## Counting, which nobody suspects\n\nReplace each category with how often it appears. It uses no target information at all, so it cannot leak, it produces one column, and it is often surprisingly effective: for fraud, a rare merchant is a different kind of thing from a common one, and the count says so directly.\n\nCheap, safe, and underused. Try it before target encoding.\n\n## What to reach for\n\nUnder about 15 categories, one-hot and stop thinking about it. Between 15 and a few hundred, one-hot with `min_frequency` set, or count encoding. Above that, count encoding or a cross-fitted target encoder, and for gradient boosting specifically, CatBoost's built-in categorical handling (which is an ordered target encoding done carefully) is frequently the best available option with no work at all."},"check":{"question":"A team target-encodes a 12,000-level merchant column using the full training set, and cross-validated AUC jumps from 0.81 to 0.97. Production performance matches the 0.81 model. What happened?","options":[{"text":"Merchant identity does not generalise, so the feature was pure noise and the model overfitted it.","correct":false,"tempting":"Comes close, but noise alone would not lift cross-validated AUC: the fold structure should have caught it, which points to the encoding rather than the feature."},{"text":"The encoding was computed before splitting, so each row's encoded value partly contains its own target; within cross-validation the folds inherit that contamination.","correct":true},{"text":"Cross-validation with 12,000 levels is unstable, so 0.97 is within the noise of the estimate.","correct":false,"tempting":"A 16-point gap is far outside plausible cross-validation noise on any reasonably sized set."},{"text":"AUC is inflated by class imbalance, and the target encoding made the imbalance worse.","correct":false,"tempting":"AUC is comparatively robust to base rate, and an encoding does not change the class distribution at all."}]},"prev":{"slug":"numerical-features","title":"Scaling, and the transforms that change what a model can see","url":"https://addaly.com/learn/machine-learning-foundations/numerical-features"},"next":{"slug":"when-a-column-has-thousands-of-levels","title":"When a column has thousands of levels","url":"https://addaly.com/learn/machine-learning-foundations/when-a-column-has-thousands-of-levels"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'Turning categories into numbers without lying about them', in Machine Learning, Foundations. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/machine-learning-foundations/categorical-encoding (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"Turning categories into numbers without lying about them","courseTitle":"Machine Learning, Foundations","module":{"index":3,"slug":"the-data-is-the-job","title":"The data is the job"},"answer":null,"keyIdea":"One-hot is the safe default because it asserts nothing about order or distance; target encoding is powerful and leaks unless computed out-of-fold and smoothed towards the base rate.","hero":{"kind":"plate","image":null,"alt":null,"caption":null},"blocks":[{"t":"h2","id":"the-mistake-that-gets-made-first","runs":[{"text":"The mistake that gets made first"}]},{"t":"p","runs":[{"text":"Given a "},{"text":"city","code":true},{"text":" column, the obvious move is to map cities to integers: Mumbai 0, Delhi 1, Chennai 2, Kolkata 3."}]},{"t":"p","runs":[{"text":"A linear model now believes Chennai is twice Delhi and that Mumbai and Kolkata are three units apart. Those statements are meaningless, but the model cannot know that; it will fit a coefficient as though the ordering were real. This is called label encoding, it is the default in a lot of tutorial code, and it is wrong for any unordered category fed to a linear or distance-based model."}]},{"t":"p","runs":[{"text":"It is "},{"text":"not","i":true},{"text":" wrong for trees, which is the source of much of the confusion. A tree can split “city in {0, 3}” versus “city in {1, 2}” and recover an arbitrary grouping given enough splits. Label encoding for LightGBM is common practice; label encoding for logistic regression is a bug."}]},{"t":"h2","id":"one-hot-the-safe-default","runs":[{"text":"One-hot: the safe default"}]},{"t":"p","runs":[{"text":"One column per category, 1 in the column for this row’s value and 0 elsewhere."}]},{"t":"code","lang":"python","label":"Python","text":"from sklearn.preprocessing import OneHotEncoder\nenc = OneHotEncoder(handle_unknown=\"ignore\", sparse_output=True,\n                    min_frequency=20)"},{"t":"p","runs":[{"text":"Three arguments carry real weight."}]},{"t":"p","runs":[{"text":"handle_unknown=\"ignore\"","b":true,"code":true},{"text":" decides what happens when production sends a category that was not in the training data. Without it, the transform raises and your service returns an error. With it, that row gets all zeros across the block and a prediction still comes out. This is a genuine choice with a downside (silently encoding an unknown city as “none of the above” may be exactly wrong for your problem) but a crash at 2am is worse, and the alternative is at least deliberate."}]},{"t":"p","runs":[{"text":"min_frequency=20","b":true,"code":true},{"text":" folds every category with fewer than 20 rows into a single “infrequent” column. This is doing real work: a category with three examples cannot support a reliable coefficient, and giving it one invites the model to memorise those three rows."}]},{"t":"p","runs":[{"text":"sparse_output=True","b":true,"code":true},{"text":" matters at scale. One-hot on 5,000 product codes gives 5,000 columns of which one is nonzero per row. Dense, a million rows would be 40 GB. Sparse, it is a few hundred megabytes. Linear models and SGD handle sparse matrices natively; some other estimators do not, and that is often the real reason to prefer another encoding."}]},{"t":"h2","id":"ordinal-where-order-is-real","runs":[{"text":"Ordinal, where order is real"}]},{"t":"p","runs":[{"text":"When the categories have an order (“never, rarely, sometimes, often, always”, or T-shirt sizes, or education level) integers are appropriate, but state the order explicitly rather than letting the encoder guess alphabetically:"}]},{"t":"code","lang":"python","label":"Python","text":"from sklearn.preprocessing import OrdinalEncoder\nOrdinalEncoder(categories=[[\"never\", \"rarely\", \"sometimes\", \"often\", \"always\"]])"},{"t":"p","runs":[{"text":"Remember what you have asserted: not just the order, but that the gaps are equal. “Never” to “rarely” gets the same numeric distance as “often” to “always”. Sometimes that is fine. When it is not, one-hot instead and let the model learn each level’s effect separately."}]},{"t":"h2","id":"target-encoding-and-the-trap-inside-it","runs":[{"text":"Target encoding, and the trap inside it"}]},{"t":"p","runs":[{"text":"Replace each category with the mean of the target for that category. Mumbai becomes 0.043 because 4.3% of Mumbai rows defaulted."}]},{"t":"p","runs":[{"text":"This is powerful: one column instead of 5,000, and it carries exactly the information the model wants. It also leaks, in a way that produces spectacular validation scores and a model that fails in production."}]},{"t":"p","runs":[{"text":"The mechanism: a category with one row gets encoded as that row’s own target. The feature now "},{"text":"contains the answer","i":true},{"text":" for that row. The model learns to trust it completely, and on unseen data where the encoding comes from other rows, that trust is unjustified."}]},{"t":"p","runs":[{"text":"The fixes are standard and non-optional:"}]},{"t":"ol","start":1,"items":[[{"text":"Compute the encoding out-of-fold.","b":true},{"text":" For each training row, use the target mean computed from the "},{"text":"other","i":true},{"text":" folds only. scikit-learn’s "},{"text":"TargetEncoder","code":true},{"text":" does this internally with cross-fitting."}],[{"text":"Smooth towards the global mean.","b":true},{"text":" A category with 4 rows should be pulled most of the way to the overall base rate; one with 40,000 rows should barely move. "},{"text":"smooth=\"auto\"","code":true},{"text":" handles it."}]]},{"t":"code","lang":"python","label":"Python","text":"from sklearn.preprocessing import TargetEncoder\nTargetEncoder(target_type=\"binary\", smooth=\"auto\", cv=5)"},{"t":"p","runs":[{"text":"Even with both, treat a target-encoded model’s validation score with a little suspicion, and compare it against the same model with one-hot encoding before believing a large gain."}]},{"t":"h2","id":"counting-which-nobody-suspects","runs":[{"text":"Counting, which nobody suspects"}]},{"t":"p","runs":[{"text":"Replace each category with how often it appears. It uses no target information at all, so it cannot leak, it produces one column, and it is often surprisingly effective: for fraud, a rare merchant is a different kind of thing from a common one, and the count says so directly."}]},{"t":"p","runs":[{"text":"Cheap, safe, and underused. Try it before target encoding."}]},{"t":"h2","id":"what-to-reach-for","runs":[{"text":"What to reach for"}]},{"t":"p","runs":[{"text":"Under about 15 categories, one-hot and stop thinking about it. Between 15 and a few hundred, one-hot with "},{"text":"min_frequency","code":true},{"text":" set, or count encoding. Above that, count encoding or a cross-fitted target encoder, and for gradient boosting specifically, CatBoost’s built-in categorical handling (which is an ordered target encoding done carefully) is frequently the best available option with no work at all."}]}],"check":{"question":[{"text":"A team target-encodes a 12,000-level merchant column using the full training set, and cross-validated AUC jumps from 0.81 to 0.97. Production performance matches the 0.81 model. What happened?"}],"options":[{"text":[{"text":"Merchant identity does not generalise, so the feature was pure noise and the model overfitted it."}],"correct":false,"tempting":[{"text":"Comes close, but noise alone would not lift cross-validated AUC: the fold structure should have caught it, which points to the encoding rather than the feature."}]},{"text":[{"text":"The encoding was computed before splitting, so each row’s encoded value partly contains its own target; within cross-validation the folds inherit that contamination."}],"correct":true,"tempting":null},{"text":[{"text":"Cross-validation with 12,000 levels is unstable, so 0.97 is within the noise of the estimate."}],"correct":false,"tempting":[{"text":"A 16-point gap is far outside plausible cross-validation noise on any reasonably sized set."}]},{"text":[{"text":"AUC is inflated by class imbalance, and the target encoding made the imbalance worse."}],"correct":false,"tempting":[{"text":"AUC is comparatively robust to base rate, and an encoding does not change the class distribution at all."}]}],"order":[2,0,3,1],"why":null},"prev":{"slug":"numerical-features","title":"Scaling, and the transforms that change what a model can see","minutes":9},"next":{"slug":"when-a-column-has-thousands-of-levels","title":"When a column has thousands of levels","minutes":8}}}