← All Posts
Deep Learning · Data Preparation and Preprocessing · Feature Selection

Mutual Information

Mutual information measures how much knowing a feature reduces uncertainty about the target, on average. It can recognize relationships that do not follow a straight line. That makes it useful for ranking candidate features, but a high score on training data is still only evidence to investigate.

The previous chapter compared input features with each other to find redundancy. Here we compare each feature with the target. This is a supervised selection step: the labels influence the ranking.

Does the feature change what we know about the label?

Start with eight training rows: four labels are 0 and four are 1. Without a feature, the two labels are equally likely in this table. Now group the rows by a feature that can take values A or B.

In the “some information” setting below, group A contains three zeros and one one; group B contains one zero and three ones. Knowing the group changes our label probabilities from 50/50 to 75/25 or 25/75. It helps, but it does not settle every prediction.

FeatureLabel 0Label 1
A31
B13
Before knowing the feature
1.0000 bit
After knowing the feature
0.8113 bit
Information gained: 1.0000 − 0.8113 = 0.1887 bit.
The information is the drop in average uncertainty. Every setting keeps eight rows and the same balanced target. The next section explains the numbers in the bars; they measure entropy, not classification error.

Put a number on that uncertainty

Entropy assigns a surprise of $-\log_2 p(y)$ to a label whose probability is $p(y)$, then averages over the labels. A rare outcome is more surprising than a common one. Let $Y$ denote the target and let the sum below run over its possible classes:

$$H(Y)=-\sum_y p(y)\log_2 p(y).$$

Log base 2 measures entropy in bits. For the balanced target, both classes have probability 1/2, giving $H(Y)=1$ bit. If a group contains only one label, its entropy is zero. Terms with zero probability contribute zero; we do not evaluate the logarithm of zero. The binary-entropy chapter develops this measure in more detail.

Inside group A, the entropy of proportions 3/4 and 1/4 is about 0.8113 bit. Group B has the reversed proportions and the same entropy. Since each group contains half the rows, the average uncertainty after observing the feature is also 0.8113 bit.

Write $H(Y\mid X)$ for that average remaining uncertainty. Each feature value $x$ contributes according to how often it occurs, $p(x)$. Mutual information, $I(X;Y)$, is what we remove from the original uncertainty:

$$\begin{aligned} H(Y\mid X)&=\sum_x p(x)H(Y\mid X=x),\\ I(X;Y)&=H(Y)-H(Y\mid X). \end{aligned}$$

For the example, $I(X;Y)=1-0.8113\approx0.1887$ bit. Unequal group sizes would need unequal weights; a tiny pure group should not dominate the score. This is the same entropy reduction used in decision-tree information gain when grouping by a split outcome.

For the true distribution, MI is nonnegative and equals zero exactly when the variables are independent. It is symmetric: $I(X;Y)=I(Y;X)$. For this discrete target, it cannot exceed $H(Y)$. It has no positive or negative direction and is not an accuracy percentage.

A curved relationship can contain useful information

Consider another balanced eight-row table. The feature takes values −2, −1, 1 and 2, each twice. The target is 1 at the outer values and 0 at the inner values. Increasing the feature does not consistently increase or decrease the target, but knowing the feature determines it.

Target10×2-2×2-1×21×22Feature value
Pearson correlation0
Mutual information1 bit
MI can detect dependence that a straight-line score misses. Each dot represents two identical rows. The result is exact for this discrete table; it does not imply that a finite-data estimator detects every relationship reliably.

Choose an estimator that matches the feature

For discrete values, we can estimate probabilities by counting feature–label pairs. For continuous measurements, almost every observed value may be distinct, so treating each raw number as a category is usually inappropriate. The score then reflects tiny training groups rather than a well-supported relationship.

Scikit-learn provides mutual_info_classif for a discrete target and mutual_info_regression for a continuous target. Mark discrete feature columns explicitly. With discrete_features='auto', dense inputs are treated as continuous and sparse inputs as discrete; an integer dtype alone does not specify the intended estimator.

Its estimators for continuous variables use nearest-neighbor distances. The neighbor count changes the bias–variance tradeoff, and a fixed random_state makes the small perturbations used for repeated values reproducible. Estimates can be noisy; negative estimates are clipped to zero. A reported zero is therefore not a proof of population independence.

Python: reproduce the tables, rank discrete columns, and check the nonlinear example
import numpy as np
from sklearn.metrics import mutual_info_score
from sklearn.feature_selection import mutual_info_classif

# Rows are feature categories; columns are target classes 0 and 1.
tables = {
    "independent": np.array([[2, 2], [2, 2]]),
    "helpful": np.array([[3, 1], [1, 3]]),
    "perfect": np.array([[4, 0], [0, 4]]),
}
for name, counts in tables.items():
    nats = mutual_info_score(None, None, contingency=counts)
    print(name, nats / np.log(2))
# independent: 0; helpful: 0.1887218755; perfect: 1 bit.

y = np.array([0, 0, 0, 0, 1, 1, 1, 1])
helpful = np.array([0, 0, 0, 1, 0, 1, 1, 1])
X = np.column_stack([helpful, np.zeros(8, dtype=int), 1 - helpful])
scores = mutual_info_classif(X, y, discrete_features=True, random_state=0)
print(scores / np.log(2))  # [0.1887218755, 0, 0.1887218755]
# A feature and its reversed copy score equally; ranking does not remove redundancy.

# The nonlinear example: four discrete feature values, each observed twice.
curved_x = np.array([-2, -2, -1, -1, 1, 1, 2, 2])
curved_y = np.array([1, 1, 0, 0, 0, 0, 1, 1])
print(np.corrcoef(curved_x, curved_y)[0, 1])  # 0
print(mutual_info_score(curved_x, curved_y) / np.log(2))  # 1 bit

# One arbitrary ID per row: the empirical groups are always pure.
id_counts = np.eye(2, dtype=int)[y]  # Eight rows, one observed label in each.
print(mutual_info_score(None, None, contingency=id_counts) / np.log(2))  # 1 bit

Scikit-learn returns MI in nats, using natural logarithms. Dividing by $\ln 2$ converts it to the bits used in the figures. The conversion changes the numerical scale but not a ranking computed on the same target.

A unique identifier exposes the estimation trap

Give each training row a different arbitrary ID and treat those IDs as discrete categories. Every observed category now contains exactly one label. The empirical conditional entropy is zero, regardless of how the labels were assigned.

Observed IDs: one row per group
ID 0 → 0ID 1 → 0ID 2 → 0ID 3 → 0ID 4 → 1ID 5 → 1ID 6 → 1ID 7 → 1
Every group is pure.
Training MI = 1 bit.
A new arbitrary ID

ID 8 → ?

The table establishes no rule for its label. A perfect training score did not create one.
Training purity can come from fragmentation. More categories create smaller groups. Check what the feature represents and whether its apparent signal survives a suitable evaluation split.

This is one reason to inspect high-cardinality features rather than accepting their empirical MI scores at face value. Coarse binning is not an automatic remedy either: it changes the information available to the estimator and adds binning choices that must be learned within training.

A good individual score is not a complete subset strategy

Two duplicate features can each have a high MI score because each tells us the same thing about the target. A simple top-k ranking may keep both. Conversely, the equally likely XOR example has zero MI between either individual input and the target, while the pair determines it. Ranking columns independently cannot capture every useful combination.

Use MI to propose a candidate subset, then fit and evaluate the downstream model. Compute scores using training rows and labels only. Choose the feature count and estimator settings through validation; repeat selection inside every training fold. The selection leakage example explains why ranking on all labels invalidates later evaluation.

Inspect score stability and the meaning of selected inputs alongside predictive quality. MI does not establish causality, and an informative feature only helps if the model can use its relationship with the target.

Compute discrete MI directly from counts

The C++ implementation works with a count table such as the interactive example. It implements the discrete calculation, not the nearest-neighbor estimator used for continuous measurements.

The count formula behind the implementation

Let $n_{xy}$ count rows with feature value $x$ and label $y$. Let $n_x$ and $n_y$ be their row and column totals, and let $N$ be the total number of observations. Under independence, the joint probability would equal the product of the two marginal probabilities. Compare the observed joint probability with that product, take its logarithm, and average over the observed pairs:

$$I=\sum_{x,y:\,n_{xy}>0}\frac{n_{xy}}{N}\log_2\!\left(\frac{Nn_{xy}}{n_xn_y}\right).$$

In the helpful table, each marginal total is 4. A cell with count 3 has ratio $8\times3/(4\times4)=1.5$; a cell with count 1 has ratio 0.5. Individual terms can be negative, so sum all nonzero cells before interpreting the total. This calculation gives the same 0.1887 bit as the entropy difference.

C++17: discrete mutual information in bits
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <iomanip>
#include <iostream>
#include <stdexcept>
#include <vector>

using Counts = std::vector<std::vector<std::uint64_t>>;

double mutual_information_bits(const Counts& counts) {
    if (counts.empty() || counts[0].empty())
        throw std::invalid_argument("empty count table");
    std::vector<long double> rows(counts.size(), 0);
    std::vector<long double> cols(counts[0].size(), 0);
    long double total = 0;
    for (std::size_t i = 0; i < counts.size(); ++i) {
        if (counts[i].size() != cols.size())
            throw std::invalid_argument("ragged count table");
        for (std::size_t j = 0; j < cols.size(); ++j) {
            long double n = counts[i][j];
            rows[i] += n; cols[j] += n; total += n;
        }
    }
    if (total == 0) throw std::invalid_argument("no observations");
    long double information = 0;
    for (std::size_t i = 0; i < counts.size(); ++i) {
        for (std::size_t j = 0; j < cols.size(); ++j) {
            if (counts[i][j] == 0) continue; // Zero-mass terms contribute zero.
            long double n = counts[i][j];
            information += (n / total) * std::log2((n * total) / (rows[i] * cols[j]));
        }
    }
    return std::max(0.0, static_cast<double>(information)); // Roundoff guard.
}

int main() {
    std::cout << std::setprecision(12);
    for (const auto& table : std::vector<Counts>{
             {{2,2},{2,2}}, {{3,1},{1,3}}, {{4,0},{0,4}}})
        std::cout << mutual_information_bits(table) << '\n';
    // 0, 0.188721875541, 1
}

Counts use unsigned integers. Empty tables, inconsistent row lengths and zero total count are rejected. Empty categories are harmless because zero-mass terms are skipped. Converting counts before multiplication avoids integer-product overflow; floating-point arithmetic still has finite precision.

References and further reading