← All Posts
Deep Learning · Data Preparation and Preprocessing · Encoding Categorical Features

Target (Mean) Encoding

Target encoding replaces a category with a statistic of the target for that category. For a binary target, it can represent a smoothed positive-outcome rate; for regression, a smoothed target mean. It is compact, but it uses labels, so the information boundary is part of the algorithm.

Keep two ideas separate: smoothing controls how much to trust a small category, while cross-fitting prevents a training row’s target from supplying its own feature. Neither replaces a genuinely held-out evaluation.

Start with a category’s observed target rate

Suppose the feature is store, and the target is whether a visit ended in a purchase: 1 for yes, 0 for no. In a constructed reference dataset, store A has 3 purchases in 4 visits, B has 1 in 1 visit, and C has 1 in 5 visits.

The raw rates are 0.75, 1.00, and 0.20. Store B’s perfect rate rests on one observation. A useful estimate can pull such a small group toward the overall rate, which is 5 purchases in 10 visits, or 0.5.

Combine local evidence with a global baseline

For category $c$, let $n_c$ be its number of reference rows and $s_c$ the sum of their targets. Let $\mu$ be the reference dataset’s overall target mean, and $\alpha\geq0$ the smoothing strength. A common encoding is

$$\operatorname{encode}(c)=\frac{s_c+\alpha\mu}{n_c+\alpha}.$$

The extra $\alpha\mu$ acts like a prior contribution with strength $\alpha$. It is a way to blend the category rate with the global rate, not a claim that extra customers were observed. Larger categories receive more influence from their own data.

A one-row category should not automatically dominate
StorePurchases / visitsRaw rateEncoded
A3 / 40.750.6250
B1 / 11.000.6000
C1 / 50.200.3333
With α = 4, A becomes 0.6250 and B becomes 0.6000. An unseen store uses the reference mean, 0.5000.
Counts and the global rate remain fixed. Move α to see the effect of shrinkage. These statistics must come from the permitted reference rows; the arithmetic alone does not prevent label leakage.

At $\alpha=0$, observed categories receive their raw means. A category with no reference rows still needs a fallback; use the reference mean instead of evaluating a zero-over-zero expression. Scikit-learn’s numeric smooth uses this blending form, while smooth="auto" estimates the amount of smoothing.

“Fit on training data” is necessary but not sufficient

Consider an identifier that appears once per training row. With no smoothing, its category mean equals that row’s target. A downstream classifier sees the answer directly in its input. New identifiers receive only the fallback mean, so the apparent training signal does not transfer.

This can happen even if validation labels were never used. The problem is self-inclusion inside the training data. Smoothing weakens the direct dependence but does not remove it. A full-training global mean also includes each training row’s target, so the reference mean must respect the same exclusion boundary.

Build each training feature from other folds

Cross-fitting divides the current training set into inner folds. For each fold, fit the encoder on the other folds and transform the held-out rows. Stitch those transformed rows back into their original order, then train the predictor on that assembled feature matrix.

The example below uses eight training rows and two fixed folds. Each reference half has a global mean of 0.5, and smoothing strength is 2. The labels in the held-out half are not read when constructing that half’s features.

Training rows also need an information boundary
RowStoreTarget used?Encoding
1ANo0.3333
2BNo0.6667
3ANo0.3333
4CNo0.5000
5AYes · 0reference
6BYes · 1reference
7CYes · 1reference
8CYes · 0reference
Fit on rows 5–8, then encode rows 1–4. For store A, the reference half has one target of 0: (0 + 2 × 0.5) / (1 + 2) = 0.3333.
Green rows receive features; the other rows supply category statistics and the global mean. This whole example occurs within the outer training set. A separate outer validation set remains untouched.

The same store can receive different training encodings in different folds because the permitted reference data differs. That is expected. After constructing the cross-fitted training matrix, also fit a mapping on all current training rows for transforming genuinely new rows.

The training transform and serving transform have different jobs

Scikit-learn’s TargetEncoder.fit_transform(X, y) returns cross-fitted features and retains a full-data mapping for later transform() calls. Calling fit(X, y).transform(X) instead uses each training row’s own target in the fitted statistics and bypasses cross-fitting.

Python: reproduce both folds and fit a complete pipeline

The explicit splitter argument below uses scikit-learn 1.9 or newer. Fixed folds keep the example traceable; select a splitting scheme appropriate to the real task.

import numpy as np
from sklearn.model_selection import KFold
from sklearn.preprocessing import TargetEncoder
from sklearn.pipeline import make_pipeline
from sklearn.linear_model import LogisticRegression

# Checked with scikit-learn 1.9.1; cv splitter objects require 1.9+.
X_train = np.array([["A"], ["B"], ["A"], ["C"],
                    ["A"], ["B"], ["C"], ["C"]])
y_train = np.array([1, 0, 1, 0, 0, 1, 1, 0])
encoder = TargetEncoder(
    target_type="binary", smooth=2,
    cv=KFold(n_splits=2, shuffle=False),  # Fixed folds match the figure.
)
training_features = encoder.fit_transform(X_train, y_train)
assert np.allclose(training_features.ravel(),
                   [1/3, 2/3, 1/3, 1/2, 3/4, 1/3, 1/3, 1/3])

# After fit_transform, transform uses mappings fitted on ALL training rows.
new_features = encoder.transform([["A"], ["D"]])
assert np.allclose(new_features, [[0.6], [0.5]])
print(new_features)

# Pipeline.fit uses the encoder's fit_transform for the training features.
model = make_pipeline(
    TargetEncoder(target_type="binary", smooth=2,
                  cv=KFold(n_splits=2, shuffle=False)),
    LogisticRegression(),
).fit(X_train, y_train)
probabilities = model.predict_proba([["A"], ["D"]])
assert probabilities.shape == (2, 2)
# This tiny table illustrates information flow, not predictive quality.
[[0.6]
 [0.5]]

The new store A row receives 0.6 from all eight training rows; unseen D receives their global rate, 0.5. These differ from some cross-fitted training values. The difference is a consequence of using more reference data at serving time, and its effect should be assessed in the outer validation.

For model selection, cross-validate the whole pipeline on outer splits. Do not target-encode the entire dataset once and then cross-validate a classifier on that matrix: an outer validation label could already have influenced another row’s encoding. See cross-validation for tuning for the outer boundary.

Rows from the same entity or future need stricter exclusions

Random inner folds are inappropriate if rows share information that would not be available for a new entity. Use group-aware inner splits when the evaluation requires unseen groups, and maintain the same outer group boundary. Merely excluding the current row is not enough if a near-duplicate from the same entity reveals its label.

For time-based prediction, target statistics must use outcomes that were available before the prediction cutoff. Random cross-fitting can read future labels. Ordered encodings are another way to restrict reference data, but a random permutation is not a substitute for chronological availability.

Why a time-series splitter may need custom handling

Scikit-learn’s internal cross-fitting requires every input row to appear in a held-out fold exactly once. A usual expanding-window time split leaves its initial training window without out-of-fold encodings. Consequently, it cannot simply be substituted without a compatible construction. A custom historical encoder can reserve a warm-up period, train downstream on rows with valid past-based encodings, and define a permitted cold-start prior.

A useful feature still needs validation

Target encoding compresses category identity into a target-related summary. Distinct categories with the same estimate become indistinguishable through that feature alone. A downstream model may still need other information to represent different interactions.

Tune smoothing and the encoder/model combination inside the evaluation procedure. More folds, less smoothing, or a large category count do not guarantee better results. For multiclass targets, scikit-learn creates a class-specific encoding for each original feature, so the output is wider than one column.

Specify target_type when necessary: integer-valued regression targets can otherwise be inferred as classes. Missing feature values may be modeled as a category; missing target labels cannot be used to calculate a supervised target statistic without a separate policy. Keep the label definition, observation window, and fallback stable in deployment.

C++: the smoothed map for a permitted reference pool

This helper implements the first figure’s statistic for finite targets and a nonnegative smoothing strength. It encodes new query categories from a supplied reference pool. To construct training features, call such a helper separately on each inner fold’s complement, as in the Python workflow; this helper alone does not perform cross-fitting.

Compile the category-statistic map
#include <cmath>
#include <iomanip>
#include <iostream>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <vector>

class TargetMean {
    struct Stats { double sum = 0; std::size_t count = 0; };
    std::unordered_map<std::string, Stats> groups;
    double globalMean, alpha;
public:
    TargetMean(const std::vector<std::string>& categories,
               const std::vector<double>& targets, double smoothing)
        : globalMean(0), alpha(smoothing) {
        if (categories.empty() || categories.size() != targets.size()
            || !std::isfinite(alpha) || alpha < 0)
            throw std::invalid_argument("invalid reference data or smoothing");
        for (std::size_t i = 0; i < targets.size(); ++i) {
            if (!std::isfinite(targets[i])) throw std::invalid_argument("nonfinite target");
            auto& stats = groups[categories[i]];
            stats.sum += targets[i];
            ++stats.count;
            globalMean += targets[i] / targets.size();
        }
    }
    double encode(const std::string& category) const {
        const auto found = groups.find(category);
        if (found == groups.end()) return globalMean;
        const auto& stats = found->second;
        return (stats.sum + alpha * globalMean) / (stats.count + alpha);
    }
};

int main() {
    // A reference pool for encoding other rows, not their own training targets.
    const TargetMean encoder(
        {"A","A","A","A","B","C","C","C","C","C"},
        {1,1,1,0,1,1,0,0,0,0}, 4);
    std::cout << std::fixed << std::setprecision(4);
    for (const std::string category : {"A","B","C","D"})
        std::cout << category << ": " << encoder.encode(category) << "\n";
}
A: 0.6250
B: 0.6000
C: 0.3333
D: 0.5000

References and further reading