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

Frequency and Count Encoding

Count encoding replaces a category with the number of reference rows that contain it. Frequency encoding divides that count by the size of the reference pool. Both answer a question about prevalence: “How common was this category in the data we counted?”

Suppose ten training orders come from three shops: four from A, three from B, and three from C. A becomes 4 with count encoding, or 0.4 with frequency encoding. These values describe the orders in this pool; they say nothing yet about which shop has better outcomes.

The same count on two different scales

For category $c$, let $n_c$ be its row count and $N$ the total number of reference rows. Each row has exactly one non-missing category in this example. The two encodings are

$$\operatorname{count}(c)=n_c,\qquad \operatorname{frequency}(c)=\frac{n_c}{N}.$$

Repeat the entire reference pool to separate absolute volume from relative prevalence. Repeating every row doubles each count and the denominator together, so the frequencies stay the same.

More counted rows, the same proportions
ShopCountFrequency
A40.4
B30.3
C30.3
N = 10. A has frequency 4 / 10 = 0.4. B and C both have frequency 0.3.
The bars compare frequencies on one fixed scale. The control duplicates observations to illustrate the arithmetic; it does not create new independent evidence about the shops.

Equal counts erase category identity

B and C both become 0.3. A model receiving only this encoded feature cannot distinguish them, even if their return rates or delivery times differ dramatically. Once the two names become the same number, a more powerful model cannot recover the missing distinction from that number alone.

This is why frequency encoding is useful when prevalence itself may matter, such as the historical activity of a merchant. It is also why it can be useful alongside an identity representation. One-hot encoding preserves different category names; frequency encoding shares a value among equally common categories.

A larger number means “more common in the reference pool.” It does not mean a higher class probability, a better shop, or an ordinal level. Target encoding estimates an outcome from labels and has different leakage risks.

Fit a reference map, then keep using it

For ordinary validation, count the training rows and save both the map and its denominator. A new batch containing B, D, B should become 0.3, 0, 0.3 under the map above. D gets zero because it was absent from the reference pool.

01Training ordersA × 4, B × 3, C × 3. Count these ten rows once.
02Saved mapA → 0.4, B → 0.3, C → 0.3. Unseen → 0.
03New ordersB, D, B → 0.3, 0, 0.3. Their batch does not redefine the map.
The lookup belongs to the fitted model. The same category keeps the same value throughout this model version, regardless of which other rows arrive beside it.

If you count that new batch instead, B becomes 2/3 and D becomes 1/3. Those numbers answer a different question: prevalence within the current batch. Predictions would then depend on how requests were batched. Use a batch-level aggregate only when that is a deliberately defined feature available at prediction time.

Python: a frozen training lookup
from collections import Counter

# This example accepts non-missing string categories.
train = ["A"] * 4 + ["B"] * 3 + ["C"] * 3
counts = Counter(train)
n_reference = len(train)
if not n_reference:
    raise ValueError("the reference pool must be nonempty")

def transform(values):
    # Lookup only: never update the reference counts while predicting.
    return [counts.get(value, 0) / n_reference for value in values]

new = ["B", "D", "B"]
print(transform(new))
assert transform(new) == [0.3, 0.0, 0.3]
assert "D" not in counts
[0.3, 0.0, 0.3]

During cross-validation, construct this map separately inside each training fold. The count uses input categories rather than target labels, so it does not have target encoding’s direct self-label leakage. Nevertheless, counting held-out inputs changes the information available to the pipeline. The fit-on-training rule keeps an ordinary inductive evaluation honest.

Define what you are counting

Our denominator counts orders. If one customer places twenty orders, they contribute twenty rows. A frequency over distinct customers would answer a different question. State the unit—rows, customers, documents, or events—and the time window before treating a count as a feature.

Counts from a larger reference pool tend to be larger even without a change in relative prevalence. Frequencies make the denominator explicit, but they still change when the underlying population changes. After retraining on a new window, save the new map together with the new model.

Missing values also change the denominator

For the four rows A, A, B, missing, excluding missing gives A a frequency of 2/3. Treating missing as its own category gives A a frequency of 2/4, B of 1/4, and missing of 1/4. Both are coherent definitions, but they measure different populations.

Pandas Series.value_counts(normalize=True) excludes missing values by default. Pass dropna=False to count them too. A reserved missing category can itself have the same frequency as an ordinary category; retain a missingness indicator when that distinction matters. The Python and C++ examples here assume non-missing string inputs.

Historical activity must be available at prediction time

For a prediction made on Monday, a merchant’s transaction count through Friday uses future information. Define the feature as the eligible history available at the decision time. A rolling count may update as events arrive, but training examples must reconstruct the same historical rule.

Unique identifiers deserve special attention. If every training ID occurs once, all training rows receive the same count of 1. A new ID receives 0. That feature provides no distinction among training IDs and creates a seen/unseen difference at evaluation. Test on the entity and time split the application will face; see group splitting and time-based splitting.

Use prevalence when it matches the task

This encoding gives one numeric output per input feature, but it still stores a map with one entry per distinct category. It reduces feature width, not vocabulary storage. Hashing makes a different tradeoff when vocabulary memory is the constraint.

For a count spanning a large range, log1p(count) can compress the numerical scale: it maps zero to zero and large counts to less extreme values. It does not restore lost identities. Treat this transformation, feature scaling, and any combination with an identity encoding as choices to compare on validation data.

Inspect performance for frequent, rare, and unseen categories. An overall improvement can hide poor behavior for new shops, particularly if their zero values were never observed during fitting.

C++: keep prediction lookups read-only

The constructor counts the reference pool. The two lookup methods reuse it and return zero for an unseen category. A const lookup using find avoids accidentally inserting new categories while predicting. Expected lookup time is constant for the hash map; stored vocabulary size grows with the number of distinct categories.

Compile the count and frequency map
#include <iomanip>
#include <iostream>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <vector>

class CountMap {
    std::unordered_map<std::string, std::size_t> counts;
    std::size_t total;
public:
    explicit CountMap(const std::vector<std::string>& reference)
        : total(reference.size()) {
        if (!total) throw std::invalid_argument("empty reference pool");
        for (const auto& category : reference) ++counts[category];
    }
    std::size_t count(const std::string& category) const {
        const auto found = counts.find(category);
        return found == counts.end() ? 0 : found->second;
    }
    double frequency(const std::string& category) const {
        return static_cast<double>(count(category)) / total;
    }
};

int main() {
    const CountMap encoder({"A", "A", "A", "A", "B", "B", "B", "C", "C", "C"});
    std::cout << std::fixed << std::setprecision(1);
    for (const std::string category : {"B", "D", "B"})
        std::cout << category << ": " << encoder.count(category)
                  << " rows, frequency " << encoder.frequency(category) << "\n";
}
B: 3 rows, frequency 0.3
D: 0 rows, frequency 0.0
B: 3 rows, frequency 0.3

References and further reading