Inference

Map probability columns through the fitted class labels

An argmax returns column positions, not class IDs. Decode with the fitted vocabulary and locate a requested class before selecting its probability.

Evidence and testing

Review record: Tensor Almanac automated evidence gate (no independent human review). Example versions, recorded outputs and limitations appear below.

A column position is not a class label

For scikit-learn LogisticRegression, predict_proba returns one column for each entry of the fitted classes_ array, in that array's order. The predict method returns class labels. Preserve this vocabulary with a probability export so its columns retain their meaning. [1]

NumPy argmax with axis=1 finds each row's maximum column position. If the class vocabulary is (10, 20, 40), position one denotes class 20. Returning the positions directly silently changes the output vocabulary to zero-based integers. The example deliberately uses nonconsecutive labels so this mistake cannot hide behind labels that happen to equal their positions. [2]

References: [1], [2]

Decode labels and select a class by identity

Run python examples/probability_class_columns.py. Its fixed probability matrix has winning column positions (1, 0, 2). Indexing the matching vocabulary produces labels (20, 10, 40). This executable fixture uses NumPy arrays; it does not fit a classifier or measure model quality.

The probability_for helper finds the requested label in the vocabulary before selecting a column. Class 10 has probabilities (0.1, 0.8, 0.1); blindly taking the second column instead returns (0.7, 0.1, 0.2). In a binary application, naming a class as the event of interest does not make it column one.

Both helpers check matrix width, unique class labels, finite values within zero and one, and row sums within an absolute tolerance of 1e-12. The fixture rejects five invalid cases, including a missing requested class. These are explicit input rules for this small helper; they do not certify that upstream metadata belongs to the fitted model.

Executed with Python 3.12.14 and NumPy 2.3.5. A separate scikit-learn 1.8.0 logistic-regression fit checked the same helpers against real predict_proba output. Exact fixture stdout and source-byte SHA-256 are preserved in private evidence.

"""Decode single-output probability rows using their fitted class vocabulary."""
import numpy as np


def checked_inputs(probabilities, classes):
    p = np.asarray(probabilities, dtype=float)
    labels = np.asarray(classes)
    if labels.ndim != 1 or labels.size == 0:
        raise ValueError("one nonempty class vector required")
    if np.unique(labels).size != labels.size:
        raise ValueError("class labels must be unique")
    if p.ndim != 2 or p.shape[1] != labels.size:
        raise ValueError("one column per class required")
    if not np.isfinite(p).all() or ((p < 0) | (p > 1)).any():
        raise ValueError("finite probabilities in range required")
    if not np.allclose(p.sum(axis=1), 1.0, rtol=0, atol=1e-12):
        raise ValueError("rows must sum to one")
    return p, labels


def decode_labels(probabilities, classes):
    p, labels = checked_inputs(probabilities, classes)
    return labels[np.argmax(p, axis=1)]


def probability_for(probabilities, classes, target):
    p, labels = checked_inputs(probabilities, classes)
    matches = np.flatnonzero(labels == target)
    if matches.size != 1:
        raise ValueError("target class absent from vocabulary")
    return p[:, int(matches[0])]


def main():
    classes = np.array([10, 20, 40])
    p = np.array([[0.1, 0.7, 0.2], [0.8, 0.1, 0.1], [0.1, 0.2, 0.7]])
    positions = np.argmax(p, axis=1)
    decoded = decode_labels(p, classes)
    assert positions.tolist() == [1, 0, 2]
    assert decoded.tolist() == [20, 10, 40]
    target_probability = probability_for(p, classes, 10)
    assert target_probability.tolist() == [0.1, 0.8, 0.1]
    assert not np.array_equal(target_probability, p[:, 1])

    permutation = np.array([2, 0, 1])
    reordered = decode_labels(p[:, permutation], classes[permutation])
    assert np.array_equal(reordered, decoded)
    metadata_only = decode_labels(p, classes[permutation])
    assert not np.array_equal(metadata_only, decoded)

    rejected = 0
    invalid = [
        (p, [10, 10, 40], 10),
        (p[:, :2], classes, 10),
        ([[float("nan"), 0.5, 0.5]], classes, 10),
        ([[0.2, 0.2, 0.2]], classes, 10),
        (p, classes, 99),
    ]
    for bad_p, bad_classes, target in invalid:
        try:
            probability_for(bad_p, bad_classes, target)
        except ValueError:
            rejected += 1
    assert rejected == len(invalid)
    print("argmax column positions:", positions.tolist())
    print("decoded class labels:", decoded.tolist())
    print("probability for class 10:", target_probability.tolist())
    print("hard-coded second column:", p[:, 1].tolist())
    print("joint column/vocabulary permutation preserves labels:", bool(np.array_equal(reordered, decoded)))
    print("vocabulary-only permutation changes labels:", metadata_only.tolist())
    print("invalid fixtures rejected:", rejected)


if __name__ == "__main__":
    main()

Recorded output

argmax column positions: [1, 0, 2]
decoded class labels: [20, 10, 40]
probability for class 10: [0.1, 0.8, 0.1]
hard-coded second column: [0.7, 0.1, 0.2]
joint column/vocabulary permutation preserves labels: True
vocabulary-only permutation changes labels: [10, 40, 20]
invalid fixtures rejected: 5

References: [1], [2]

Move the vocabulary with its probability columns

Reordering both the probability columns and their vocabulary leaves the fixture's decoded labels unchanged because each value retains its class identity. Reordering only the vocabulary instead changes the decoded labels to (10, 40, 20). Shape and row-sum checks still pass: numerical validity alone cannot detect mismatched metadata.

For a fitted logistic-regression model, pass its actual classes_ alongside its predict_proba output. Keep this pair together across exports and model replacements. The helper covers finite, single-output classification with a one-dimensional numeric or string vocabulary; multioutput and multilabel formats need their own contracts. An absent class is rejected rather than assigned a fabricated zero probability. For exact ties, this helper inherits NumPy's first-maximum rule, so tie decisions depend on column order. [1, 2]

References: [1], [2]

Sources

  1. scikit-learn LogisticRegression: classes_, predict and probability-column order
  2. NumPy argmax: axis positions and first-maximum behavior