"We fit the model on
Xandy, withXof shape(n, p)andyof shape(n,)."
Read in full: the model is trained on a table of explanatory variables holding n rows and p columns, together with a column of n answers, the i-th answer corresponding to the i-th row.
The convention is not a stylistic habit. It is typed. The case of the letter carries information about the mathematical nature of the object, and a reader who knows the convention extracts the dimensionality of every symbol in a formula without being told. That is the entire point of it.
| Mathematical object | Conventional typography | Example | Number of dimensions |
|---|---|---|---|
| Scalar | Lowercase italic, or a Greek letter | n, p, α | 0 |
| Vector | Lowercase, often bold | y, x, β | 1 |
| Matrix | Uppercase | X, A, Σ | 2 |
This notational discipline predates machine learning by several decades. It is attributed to Alston Householder (Principles of Numerical Analysis, 1953) and was carried into the standard literature of numerical linear algebra from there.
Direct consequence. X is uppercase because it is a matrix: two axes,
observations and variables. y is lowercase because it is a vector: one
value per observation, one axis.
The second source of the convention is the matrix formulation of the linear model, standard in statistics:
y = X β + ε, with the least-squares estimator β̂ = (Xᵀ X)⁻¹ Xᵀ y
The argument from dimensional coherence. Read the estimator one factor at a
time. Xᵀ X is a product of a p × n matrix by an n × p matrix, which is defined
only if X is genuinely two-dimensional, and which yields a p × p matrix. Its
inverse is p × p. Xᵀ y is a product of a p × n matrix by a vector of length n,
which is defined only if y has exactly as many components as X has rows, and
which yields a vector of length p. The product of the two is therefore a vector
of length p: one coefficient per explanatory variable, which is precisely what
β̂ must be.
Every step of that reading depends on X being a matrix and y a vector. The notation is what makes the check possible at a glance, and the same discipline propagates into the shapes that the libraries enforce at runtime.
Rigorous definition
Given a dataset of n observations described by p explanatory variables, the matrix of explanatory variables X is the element of whose entry is the value taken by the j-th explanatory variable on the i-th observation. The i-th row of X, written , is the feature vector of observation i.
Synonyms: design matrix, predictor matrix, feature matrix.
In plain terms
The table of information handed to the model, stripped of the column to predict and of every column that has no explanatory role.
Point of caution
X contains neither the target, nor identifiers, nor traceability columns. Every column left in place will be used by the algorithm, including an identifier that happens to correlate with the target by accident of how the file was assembled (chapters 005 and 028).
Rigorous definition
The target vector y is the element of in regression, or of in classification where 𝒞 is the finite set of classes, whose i-th component is the value of the target variable observed for the i-th observation. The correspondence is positional: is the answer attached to row of X.
Synonyms: response vector, dependent variable, target, labels.
In plain terms
The column of correct answers, in the order of the rows of the table.
Point of caution
y is a one-dimensional object, of shape (n,) and not (n, 1). The
distinction has no mathematical content and a very real effect in code
(section 2.3).
The dataset used throughout this chapter is simulated so that every figure below
can be reproduced exactly. It describes 120,000 card transactions, carries
fourteen explanatory variables, two non-modeling columns and one binary target,
status, whose two levels are Fraud and Normal.
import numpy as np
import pandas as pd
rng = np.random.default_rng(7)
n = 120_000
is_fraud = np.zeros(n, dtype=bool)
is_fraud[rng.choice(n, 348, replace=False)] = True
night = np.where(np.arange(24) < 6, 0.085, 0.0272222)
hour = np.where(is_fraud,
rng.choice(24, n, p=night / night.sum()),
rng.integers(6, 24, n))
amount = np.clip(np.round(np.where(is_fraud,
rng.lognormal(4.3, 1.1, n),
rng.lognormal(3.4, 1.0, n)), 2), 1.0, 9_500.0)
df = pd.DataFrame({
"transaction_id": [f"T-{i + 1:07d}" for i in range(n)],
"operation_date": pd.to_datetime("2026-01-01")
+ pd.to_timedelta(rng.integers(0, 180, n), unit="D"),
"amount": amount,
"hour": hour,
"day_of_week": rng.integers(0, 7, n),
"days_since_last_transaction": np.round(np.abs(rng.normal(2.4, 2.0, n)), 2),
"transactions_last_24h": rng.poisson(np.where(is_fraud, 3.2, 2.1), n),
"amount_to_median_ratio": np.round(amount / rng.uniform(25, 70, n), 3),
"distance_from_home_km": np.round(rng.exponential(np.where(is_fraud, 55, 22), n), 1),
"distance_from_last_transaction_km": np.round(rng.exponential(np.where(is_fraud, 40, 18), n), 1),
"card_age_months": rng.integers(1, 145, n),
"merchant_risk_score": np.round(rng.beta(np.where(is_fraud, 2.6, 1.6), 4.0, n), 3),
"is_online": (rng.random(n) < np.where(is_fraud, 0.55, 0.31)).astype(int),
"is_foreign_country": (rng.random(n) < np.where(is_fraud, 0.14, 0.04)).astype(int),
"chip_used": (rng.random(n) < np.where(is_fraud, 0.55, 0.86)).astype(int),
"failed_attempts_1h": rng.poisson(np.where(is_fraud, 0.45, 0.06), n),
"status": np.where(is_fraud, "Fraud", "Normal"),
})
# Two enrichment fields are not always populated by the upstream system.
df.loc[rng.choice(n, 1_800, replace=False), "merchant_risk_score"] = np.nan
df.loc[rng.choice(n, 900, replace=False), "days_since_last_transaction"] = np.nan
df.to_csv("card_transactions.csv", index=False)
print(df.shape)
print(df["status"].value_counts())
print("rows carrying at least one missing value:", int(df.isna().any(axis=1).sum()))(120000, 17)
status
Normal 119652
Fraud 348
Name: count, dtype: int64
rows carrying at least one missing value: 2688The split itself is three lines.
import pandas as pd
df = pd.read_csv("card_transactions.csv")
TARGET, NOT_MODELED = "status", ["transaction_id", "operation_date"]
X = df.drop(columns=[TARGET] + NOT_MODELED)
y = df[TARGET]Interpretation. X is built by subtraction, never by enumerating the columns to keep. The consequence is deliberate and worth stating: a column added upstream — by a new join, an enriched export, a schema change in the source system — enters the model automatically, without anyone deciding that it should. Building X by subtraction is the right default because it fails loudly when the schema changes rather than silently dropping a variable, but it makes an explicit review of the source schema part of the routine rather than an optional courtesy.
Variants, and one form that is simply wrong. An uppercase Y is justified
for a multi-output target of shape (n, t) — several values predicted jointly
for the same observation (chapter 048) — and is wrong for a single target, where
it announces a matrix that does not exist. Part of the neural-network literature
places observations in columns, giving X the shape (p, n); the variant is
internally coherent and incompatible with the scikit-learn interface, which
requires observations in rows. Mixing the two conventions inside one codebase
produces transpositions that run without error and train on nonsense.
| Symbol | Name | scikit-learn equivalent | What it counts |
|---|---|---|---|
| n | Number of observations | n_samples | Rows of X, length of y |
| p | Number of explanatory variables | n_features | Columns of X |
| k | Number of classes | n_classes | Distinct levels of y |
Some references write d for the number of variables and m for the number of observations. The letter is immaterial; the position is not. The first axis is always the observation axis.
The equality of lengths is checked automatically by scikit-learn on every call to
fit. The row-by-row correspondence is checkable by no library whatsoever.
It is a property of the code that produced X and y, and nothing in the arrays
themselves records whether it still holds. That asymmetry is the whole subject of
sections 2.4 and 2.5.
Vocabulary reminder. The shape of an array is the tuple of the sizes of its
axes. (n,) and (n, 1) hold the same number of values and do not have the same
rank: a vector on one side, a one-column matrix on the other.
print("X shape :", X.shape)
print("y shape :", y.shape)
print("y ndim :", y.ndim)
print("lengths match :", X.shape[0] == y.shape[0])
print()
print(list(X.columns))X shape : (120000, 14)
y shape : (120000,)
y ndim : 1
lengths match : True
['amount', 'hour', 'day_of_week', 'days_since_last_transaction',
'transactions_last_24h', 'amount_to_median_ratio', 'distance_from_home_km',
'distance_from_last_transaction_km', 'card_age_months', 'merchant_risk_score',
'is_online', 'is_foreign_country', 'chip_used', 'failed_attempts_1h']Interpretation. 120,000 observations described by 14 variables, and 120,000
target values. The rank of 1 on y confirms that it is a vector and not a
one-column table. Printing the column list is worth the two extra characters: it
is the only cheap way to confirm that the target has actually left X, and that no
identifier has come back in through a merge.
The check costs nothing and is repeated after every filtering step, every merge and every resampling operation. Cheap and repeated beats thorough and occasional.
y_correct = df["status"] # pandas Series -> shape (120000,) ndim 1
y_wrong = df[["status"]] # pandas DataFrame -> shape (120000, 1) ndim 2One pair of brackets separates the two. Handed to an estimator, the second triggers the following warning, which does not interrupt execution and repeats at every fold of every cross-validation run:
DataConversionWarning: A column-vector y was passed when a 1d array was
expected. Please change the shape of y to (n_samples,), for example using
ravel().Fix. Write df["status"], or df[["status"]].to_numpy().ravel() when the
two-dimensional object comes from upstream code you do not control.
Point of caution. The warning is benign in this case because scikit-learn
silently reshapes the array for you. The habit it encourages is not benign. A
practitioner who learns to ignore DataConversionWarning also ignores the
warnings that announce a real defect, and a training log dense with repeated
warnings stops being read at all.
The most widespread scenario consists in filtering X after y has already been extracted.
from sklearn.ensemble import RandomForestClassifier
X = df.drop(columns=["transaction_id", "operation_date", "status"])
y = df["status"]
X = X.dropna() # 2,688 rows removed from X, none from y
RandomForestClassifier().fit(X, y)ValueError: Found input variables with inconsistent numbers of samples:
[117312, 120000]Interpretation. This error is the benign case, because it is loud. The consistency check fires before any learning happens, the message names both lengths, and the fix is obvious once read. The correct form filters the source table before the split:
df_clean = df.dropna()
X = df_clean.drop(columns=["transaction_id", "operation_date", "status"])
y = df_clean["status"]The dangerous case preserves the lengths and destroys the order.
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score
X = df.drop(columns=["transaction_id", "operation_date", "status"])
y = (df["status"] == "Fraud").astype(int)
X_sorted = X.sort_values("amount") # X reordered, y untouched
print("lengths match :", X_sorted.shape[0] == y.shape[0])
print("aligned :", round(cross_val_score(RandomForestClassifier(random_state=42, n_jobs=-1),
X, y, cv=5, scoring="roc_auc").mean(), 3))
print("misaligned:", round(cross_val_score(RandomForestClassifier(random_state=42, n_jobs=-1),
X_sorted, y, cv=5, scoring="roc_auc").mean(), 3))lengths match : True
aligned : 0.898
misaligned: 0.492Interpretation. No exception is raised. Estimators convert pandas objects to NumPy arrays and ignore the index: the correspondence is destroyed with no signal at all, and the model spends its training budget learning an association between transactions and the labels of other transactions. The result is the only symptom available — a ROC AUC of 0.492 where the same code on the same data yields 0.898.
An AUC sitting on 0.5 in a problem the business believes to be predictable is the signature of a misalignment, not of a bad algorithm. The reflex it should provoke is to audit the construction of X and y, not to switch to a different model family.
The guard rail. Once X and y have been separated, the pandas index is the only remaining witness to their correspondence.
print("lengths match :", X_sorted.shape[0] == y.shape[0])
print("indexes match :", X_sorted.index.equals(y.index))
assert X_sorted.index.equals(y.index), "X and y are no longer aligned"lengths match : True
indexes match : False
AssertionError: X and y are no longer aligned| Operation applied to X alone | Lengths preserved | Correspondence preserved | Severity |
|---|---|---|---|
dropna(), boolean filtering, query() | No | No | Benign — raises ValueError |
sort_values() | Yes | No | Critical — silent |
sample(frac=1) | Yes | No | Critical — silent |
reset_index(drop=True) on one side only | Yes | Yes at first, then destroyed by any later join or assert | Critical — silent, and it removes the witness |
groupby().transform() | Yes | Yes | Safe |
| Adding or dropping a column | Yes | Yes | Safe |
The rule that covers all six rows. Every operation that changes the set of rows or their order is applied to the source table, before X and y are separated. After the split, X and y are read, never reordered.
Point of caution on reset_index(drop=True). It is the worst of the three
silent operations, because it is usually applied with the intention of tidying
up. It leaves the data in place and deletes the only evidence that would have
let a later assert detect an earlier reordering. Reindex both objects together
or neither.
Rigorous definition
The value of the target variable attached to an observation in a supervised learning dataset, taken as the reference answer for that observation. The ordered set of labels constitutes the target vector y.
In plain terms
The correct answer, already known, attached to one row of the table.
Scope of the term
In the strict sense, a label is a categorical value naming the category the observation belongs to. In classification, a label is a class. In regression, the associated quantity is a numeric target value and not a label in the strict sense, even though professional usage extends the word to both situations and speaks of labeled data for any dataset carrying an observed target.
Point of caution
A label is not an absolute truth. It is a recorded value, which may be wrong,
or may reflect the decision of a fallible operator. A fraud dataset contains the
frauds that were detected; the rest carry the label Normal and are
indistinguishable, in the file, from genuinely normal transactions.
| Criterion | Classification | Regression |
|---|---|---|
| Nature of | A category | A real number |
| Rigorous term | Class label | Target value, response value |
| Codomain | A finite set 𝒞 of cardinality k | ℝ or an interval of ℝ |
| Distance between two values | Undefined | Defined and interpretable |
| Order between two values | Undefined, except in the ordinal case | Total and meaningful |
| Example | status ∈ {Fraud, Normal} | claim_amount = $4,380.50 |
Point of caution. The distinction cannot be read off the storage type of the
column. A target coded 0 and 1 is numeric in the sense of pandas and
categorical in the statistical sense; a target holding the strings "1" through
"5" for a rating may be ordinal. The criterion is the nature of the
phenomenon measured, and the operational test is whether the difference between
two values means anything (chapter 010).
The consequence is immediate and often overlooked. Nothing prevents a regressor from being fitted on a target coded 0 and 1; the code runs, the residuals are computed, an R² comes out. What has been produced is not a classifier, its outputs are not probabilities, and none of the classification metrics apply to it.
Between the phenomenon and the value written into y sit two fallible steps: a detection and a recording. The label is the output of that chain, not the phenomenon itself.
| Step | Failure mode | Effect on the model |
|---|---|---|
| Detection | Undetected frauds are labeled Normal | The positive class is under-counted; measured recall overstates true recall |
| Annotation | Two analysts disagree on borderline cases | The learnable signal is capped by the disagreement rate |
| Recording | Late updates, retroactive corrections | The label depends on the extraction date, and the dataset is not reproducible |
| Definition | The written rule changes over the history | Two periods carry incompatible labels under one column name |
Where several operators annotate the same observations, their rate of
disagreement bounds the performance attainable by any model. No algorithm can
learn a distinction more sharply than the labels encode it. Raw agreement is a
poor summary of that ceiling because two annotators who both mark nearly
everything Normal agree almost perfectly by construction. Cohen's kappa
(Cohen, 1960) corrects for the agreement expected by chance.
Rigorous definition
For two annotators labeling the same m observations into the same categories, let be the observed proportion of agreement and the proportion expected if the two annotators labeled independently, with their respective marginal frequencies. Cohen's kappa is:
Reading the scale. κ = 1 is perfect agreement; κ = 0 is agreement no better than chance; κ < 0 is systematic disagreement. The conventional bands are due to Landis and Koch (1977): below 0.20 slight, 0.21 to 0.40 fair, 0.41 to 0.60 moderate, 0.61 to 0.80 substantial, above 0.80 almost perfect. The bands are a convention, not a result.
In plain terms
How much the two annotators agree, over and above what two people would agree on by simply guessing at the same rates.
Point of caution
Kappa is depressed by a skewed marginal distribution. On a class present in 2% of the sample, chance agreement is already very high, so a modest kappa may coexist with an excellent raw agreement rate. Report both, and report the counts.
The block below simulates a double review of 500 flagged transactions by two fraud analysts. Both are competent; neither is infallible.
import numpy as np
import pandas as pd
from sklearn.metrics import cohen_kappa_score
rng = np.random.default_rng(11)
m = 500
true_fraud = rng.random(m) < 0.18
a1 = np.where(rng.random(m) < 0.95, true_fraud, ~true_fraud)
a2 = np.where(rng.random(m) < 0.93, true_fraud, ~true_fraud)
review = pd.DataFrame({"analyst_a": np.where(a1, "Fraud", "Normal"),
"analyst_b": np.where(a2, "Fraud", "Normal")})
print(pd.crosstab(review["analyst_a"], review["analyst_b"]))
print("Raw agreement :", round((review.analyst_a == review.analyst_b).mean(), 4))
print("Cohen's kappa :", round(cohen_kappa_score(review.analyst_a, review.analyst_b), 4))analyst_b Fraud Normal
analyst_a
Fraud 96 22
Normal 31 351
Raw agreement : 0.894
Cohen's kappa : 0.7136Interpretation. The two analysts agree on 89.4% of the files, which sounds close to settled. Corrected for chance, the agreement is 0.71 — substantial, not excellent. Fifty-three of the five hundred files are classified differently by two qualified people applying the same rule book. Those fifty-three carry a label whose value depends on which analyst happened to open the case, and a model trained on them is being asked to reproduce a coin flip.
Operational consequence. Before attributing a disappointing result to the algorithm, audit the labeling: who labeled, under what written definition, with what measured agreement, and over what period. A model whose measured performance approaches the inter-annotator agreement rate has extracted what the labels contain. Further tuning will not help; relabeling might.
Rigorous definition
A level of the target variable in a classification problem. The set of k classes forms a partition of the space of observations: every observation belongs to one class and to exactly one. The classes are therefore mutually exclusive and collectively exhaustive.
In plain terms
One of the possible categories of the answer, and only one per row.
Cardinality
k = 2 in binary classification, k > 2 in multiclass. Multilabel classification lifts the exclusivity assumption and lets one observation carry several labels at once (chapter 011); it is a different formalism, not a special case of this one.
Point of caution
A class is a level of the target. The levels of an explanatory variable are categories, and the two are handled by entirely different machinery: classes are encoded once, with a decision attached (section 4.1); categories are encoded as indicator columns, and the choice of scheme is a modeling question (chapter 022).
Second point of caution
The partition must be exhaustive in fact, not only on paper. A target with levels
Fraud and Normal is exhaustive only if every transaction reviewed falls into
one of the two. If reviewers may also mark a case "undetermined" and those rows
are silently dropped, the model is being fitted on a filtered population and its
performance will not transfer to the full stream.
Most estimators work with numeric labels. The conversion is mechanical and carries an implicit decision: which class receives the code 1.
from sklearn.preprocessing import LabelEncoder
encoder = LabelEncoder().fit(df["status"])
print(dict(zip(encoder.classes_, encoder.transform(encoder.classes_).tolist()))){'Fraud': 0, 'Normal': 1}Interpretation. LabelEncoder sorts the levels in lexicographic order.
"Fraud" precedes "Normal" in the alphabet, so fraud receives the code 0 and
normal behavior receives the code 1. Since the asymmetric metrics default to
pos_label=1, the positive class silently becomes Normal — the exact inverse
of the business intent, with no warning of any kind.
The failure is not a scikit-learn defect. The library has no way to know which level matters; sorting is the only deterministic rule available to it. The defect is in leaving the decision to the alphabet.
Recommended form. State the encoding rather than inherit it.
y = (df["status"] == "Fraud").astype(int)
print(y.value_counts())status
0 119652
1 348
Name: count, dtype: int64Interpretation. The code 1 designates fraud by construction and not by alphabetical accident, and a single line of code documents the decision for every future reader. The expression is self-verifying: anyone reading it knows immediately what "positive" means in every metric computed downstream.
| Convention | Content | Context |
|---|---|---|
| 0 / 1 | 0 = absence of the phenomenon, 1 = presence | Standard in binary classification |
| Lexicographic order | Levels are sorted, then numbered from 0 | Default behavior of scikit-learn |
| −1 / +1 | Negative and positive class | Margin formulations: SVM, boosting theory (chapters 039 and 041) |
classes_ | Attribute exposing the order the estimator retained | To be inspected systematically |
| One-hot on the target | k indicator columns | Multiclass with neural networks; not the scikit-learn interface |
Point of caution. The −1 / +1 convention is not interchangeable with 0 / 1. It appears in the mathematical formulation of margin-based methods, where the product y · f(x) is positive exactly when the prediction is correct. The scikit-learn API expects 0 / 1 regardless; the −1 / +1 form belongs to the papers, not to the calling code.
classes_ and the column order of predict_probaThe matrix returned by predict_proba holds one column per class, ordered
according to estimator.classes_. The ordering is not documented per call; it is
readable on the fitted object, and it must be read.
The split below is reused in sections 5 and 6. It is stratified, for the reason given in section 6.1: with 348 frauds in 120,000 rows, an unstratified draw can easily leave the test set with a materially different fraud rate.
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import (accuracy_score, balanced_accuracy_score,
confusion_matrix, f1_score, matthews_corrcoef,
precision_score, recall_score, roc_auc_score)
X = df.drop(columns=["transaction_id", "operation_date", "status"])
y = (df["status"] == "Fraud").astype(int)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42)
print("train:", X_train.shape, "| test:", X_test.shape,
"| frauds in test:", int(y_test.sum()))train: (96000, 14) | test: (24000, 14) | frauds in test: 70model = RandomForestClassifier(n_estimators=300, class_weight="balanced",
random_state=42, n_jobs=-1).fit(X_train, y_train)
print("classes_:", model.classes_)
print("AUC with P(Fraud) =", round(roc_auc_score(y_test, model.predict_proba(X_test)[:, 1]), 4))
print("AUC with P(Normal) =", round(roc_auc_score(y_test, model.predict_proba(X_test)[:, 0]), 4))classes_: [0 1]
AUC with P(Fraud) = 0.879
AUC with P(Normal) = 0.121Interpretation. With classes_ equal to [0 1] and 1 designating fraud,
column 1 does carry the probability of fraud, and the AUC of 0.879 measures a
genuinely discriminating model. Column 0 carries the complementary probability,
and scoring against it returns 1 − 0.879 = 0.121.
Now the same experiment with the text labels left unencoded.
Xtr, Xte, ytr, yte = train_test_split(X, df["status"], test_size=0.2,
stratify=df["status"], random_state=42)
text_model = RandomForestClassifier(n_estimators=300, class_weight="balanced",
random_state=42, n_jobs=-1).fit(Xtr, ytr)
print("classes_:", text_model.classes_)
print("AUC using column 1 against the Fraud indicator:",
round(roc_auc_score((yte == "Fraud").astype(int),
text_model.predict_proba(Xte)[:, 1]), 4))classes_: ['Fraud' 'Normal']
AUC using column 1 against the Fraud indicator: 0.1223Interpretation. Nothing failed. The model is exactly as good as before; only
the column read is wrong. classes_ now sorts Fraud first, so column 1 holds
the probability of being normal, and evaluating it against the fraud
indicator returns 0.1223 — a value far below 0.5, obtained by a model whose
actual discriminating power is 0.878.
The diagnostic worth memorizing. An AUC clearly below 0.5 almost never
means a model that has learned the phenomenon backwards. It means the score
column, or the label encoding, is inverted. Reading classes_ settles it in one
line.
Rigorous definition
In a binary classification problem, the positive class is the level of the target variable designated as the event of interest. It serves as the reference for computing TP, FP, FN and TN, and therefore for every asymmetric metric derived from them; the negative class is the complementary level.
The vocabulary is borrowed from medical diagnosis, where a "positive" test signals the presence of the condition being looked for.
In plain terms
The positive class is what you are trying to detect.
The nature of the concept
The designation is a methodological decision, taken during problem framing, in the same way that the unit of analysis is decided (chapter 005). It is not a property of the data and it is not discovered by inspecting the file.
Point of caution
"Positive" does not mean "favorable". In a screening model the positive class is
Affected; in a churn model it is Churned; in a default model it is
Default. The term is descriptive, never evaluative.
A patient whose test comes back positive has not received good news. The laboratory expressed no judgment of value. It reported that the substance being looked for was detected.
A test designed to detect a disease is calibrated on the disease. Nobody asks what the sensitivity of a test is for good health: the question is grammatically well formed and clinically meaningless, because the instrument was built around one event and reports on that event.
The positive class of a model obeys the same logic. It is the substance being looked for. And as in the laboratory, two numbers describe the instrument's behavior on that substance and on nothing else: how often it finds the substance when it is there, and how often what it reports is genuinely the substance. Both are undefined until the substance has been named.
The three criteria almost always converge: what an organization is trying to detect is also what is rare, and also what is expensive to miss. Where they diverge, the first prevails, because it is the business criterion and the other two are proxies for it.
| Use case | Positive class | Negative class | Action triggered |
|---|---|---|---|
| Fraud detection | Fraud | Normal | Block, or send to manual review |
| Medical screening | Affected | Healthy | Follow-up examination |
| Customer churn | Churned | Retained | Retention offer |
| Predictive maintenance | Failure within 30 days | Nominal operation | Preventive intervention |
| Credit default | Default | Repaid | Refusal, or reinforced guarantees |
| Quality control certifying conformity | Conforming | Defective | Release of the batch |
Note the last row. It is the exception that shows the criterion is genuinely the business one: a certification process acts on the conforming pieces, which are the majority, and designating them positive is legitimate. Section 6.2 returns to it.
A model is trained on the 96,000 transactions of the training set and evaluated on the 24,000 held-out ones, which contain 70 frauds. The operating threshold is set at 0.02, an alert budget the review team can absorb.
model = RandomForestClassifier(n_estimators=300, class_weight="balanced",
random_state=42, n_jobs=-1).fit(X_train, y_train)
fraud_score = model.predict_proba(X_test)[:, 1]
y_pred = (fraud_score >= 0.02).astype(int)
tn, fp, fn, tp = confusion_matrix(y_test, y_pred).ravel()
print(f"TN={tn} FP={fp} FN={fn} TP={tp}")TN=23591 FP=339 FN=26 TP=44| Predicted Normal | Predicted Fraud | |
|---|---|---|
| Actual Normal | TN = 23,591 | FP = 339 |
| Actual Fraud | FN = 26 | TP = 44 |
The same predictions, the same test set, the same model. Only the designated positive class changes between the two halves of the block below.
for name, value in [
("Accuracy", accuracy_score(y_test, y_pred)),
("Balanced accuracy", balanced_accuracy_score(y_test, y_pred)),
("Matthews coefficient", matthews_corrcoef(y_test, y_pred)),
("Precision Fraud", precision_score(y_test, y_pred, pos_label=1)),
("Recall Fraud", recall_score(y_test, y_pred, pos_label=1)),
("F1 Fraud", f1_score(y_test, y_pred, pos_label=1)),
("Precision Normal", precision_score(y_test, y_pred, pos_label=0)),
("Recall Normal", recall_score(y_test, y_pred, pos_label=0)),
("F1 Normal", f1_score(y_test, y_pred, pos_label=0))]:
print(f"{name:<22}: {value:.4f}")Accuracy : 0.9848
Balanced accuracy : 0.8072
Matthews coefficient : 0.2644
Precision Fraud : 0.1149
Recall Fraud : 0.6286
F1 Fraud : 0.1943
Precision Normal : 0.9989
Recall Normal : 0.9858
F1 Normal : 0.9923Interpretation. One model, one set of predictions, and an F1-score of 0.1943 or 0.9923 depending on which level is called positive — a factor of five, and on scarcer positives the gap widens to an order of magnitude. Read as a fraud detector the system is mediocre: it raises 383 alerts to catch 44 frauds, so roughly eight alerts in nine are wasted analyst time, and 26 frauds out of 70 pass through untouched. Read as a normality classifier the same system is close to flawless, and the 26 missed frauds — the entire reason the project exists — disappear into the fourth decimal place.
Neither reading is a computational error. Both figures are correct. Only one of them answers the question the organization asked, and the number alone does not say which.
Consequence for reporting. A metric reported without naming its class is not
a result. "The F1-score of the model is 0.99" is not a claim that can be checked,
challenged or compared. "The F1-score on the Fraud class, at threshold 0.02, is
0.194" is.
The inversion can be verified directly, by swapping the roles and recomputing.
y_inv, pred_inv = 1 - y_test, 1 - y_pred
print("Accuracy inverted :", round(accuracy_score(y_inv, pred_inv), 4))
print("Balanced acc inverted :", round(balanced_accuracy_score(y_inv, pred_inv), 4))
print("MCC inverted :", round(matthews_corrcoef(y_inv, pred_inv), 4))
print("Precision(Normal) == NPV of the Fraud designation:",
round(precision_score(y_inv, pred_inv, pos_label=1), 4), "==", round(tn / (tn + fn), 4))
print("Recall(Normal) == specificity of the Fraud designation:",
round(recall_score(y_inv, pred_inv, pos_label=1), 4), "==", round(tn / (tn + fp), 4))Accuracy inverted : 0.9848
Balanced acc inverted : 0.8072
MCC inverted : 0.2644
Precision(Normal) == NPV of the Fraud designation: 0.9989 == 0.9989
Recall(Normal) == specificity of the Fraud designation: 0.9858 == 0.9858| Metric | Behavior when the positive class is inverted |
|---|---|
| Accuracy, balanced accuracy, log loss, Matthews coefficient, Cohen's kappa | Invariant — the three figures above are unchanged to the fourth decimal |
| Precision | Becomes the negative predictive value of the original designation |
| Recall | Becomes the specificity of the original designation |
| F1-score, PR-AUC | Change, drastically under imbalance |
| ROC AUC | Unchanged if the score used is that of the new positive class; becomes 1 − AUC otherwise (section 4.3) |
How to read the table. The invariant metrics are those built symmetrically on the four cells of the confusion matrix. The metrics that move are those built on one row or one column of it. That is not a defect: precision and recall are asymmetric by design, because the question they answer is asymmetric. The error is not using them, it is using them without stating the reference class.
These metrics are defined one by one in chapters 052 to 065. What must be
acquired here is their dependence on the reference class. The same
designation also governs how the confusion matrix is read, where swapping the
roles turns a false negative into a false positive (chapter 052); the direction
in which the decision threshold moves (chapter 062); and which class is
reinforced by class_weight and by oversampling (chapter 051).
| Mistake | How it shows up | Fix |
|---|---|---|
| Letting alphabetical order decide | Fraud encoded as 0 by LabelEncoder | Encode the class of interest as 1, explicitly |
| Confusing positive with favorable | Retained designated positive in a churn model | Positive = the event being looked for |
| Designating the majority class | Flattering metrics that cannot be acted on | Designate the rare event to be detected |
Omitting pos_label with text labels | ValueError: pos_label=1 is not a valid label | Pass pos_label="Fraud", or recode to 0/1 |
Reading confusion_matrix().ravel() without checking the order | TN, FP, FN and TP swapped | Set labels= explicitly and check classes_ |
The last two are worth seeing run.
y_true = pd.Series(["Normal", "Fraud", "Normal", "Fraud", "Normal"])
y_pred = pd.Series(["Normal", "Normal", "Fraud", "Fraud", "Normal"])
try:
f1_score(y_true, y_pred)
except ValueError as exc:
print("ValueError:", exc)
print("with pos_label='Fraud':", round(f1_score(y_true, y_pred, pos_label="Fraud"), 4))ValueError: pos_label=1 is not a valid label. It should be one of ['Fraud', 'Normal']
with pos_label='Fraud': 0.5y_true = np.array(["Normal"] * 8 + ["Fraud"] * 4)
y_pred = np.array(["Normal"] * 6 + ["Fraud"] * 2 + ["Normal"] * 1 + ["Fraud"] * 3)
print("default label order :", confusion_matrix(y_true, y_pred).ravel())
print("labels=['Normal','Fraud']:", confusion_matrix(y_true, y_pred,
labels=["Normal", "Fraud"]).ravel())default label order : [3 1 2 6]
labels=['Normal','Fraud']: [6 2 1 3]Interpretation. The idiom tn, fp, fn, tp = confusion_matrix(...).ravel() is
correct only when the label order is [negative, positive]. With text labels
sorted alphabetically, Fraud comes first, and the unpacking assigns TN = 3,
FP = 1, FN = 2, TP = 6 where the true values are TN = 6, FP = 2, FN = 1, TP = 3.
Every downstream metric computed by hand from those four numbers is wrong, and no
error is raised at any point.
Recommendation. The designation belongs to the framing of the problem, on the same footing as the unit of analysis: agreed with the business, written down in plain language, and materialized in the code by a single explicit expression placed where anyone will read it.
Rigorous definition
The majority class is the one with the largest count in the dataset under consideration; the minority class is the one with the smallest. In multiclass problems the qualification extends by decreasing count.
The prevalence of the positive class is the proportion of observations belonging to it, π = n₁ / n. The imbalance ratio is the quotient IR = / .
In plain terms
The most frequent class and the rarest one; the percentage of positive cases, and the number of negatives per positive.
Point of caution
These qualifications are empirical. They describe the composition of a sample, not a property of the phenomenon. A resampled training set may show equal counts while the phenomenon remains rare in the population — which is why the test set is never rebalanced (chapter 051), and why the prevalence quoted in a report must always say which set it was measured on.
Second point of caution
What limits learning is not the ratio alone but the absolute count of the minority class. A ratio of 100:1 with 5,000 positives is workable; the same ratio with 30 positives is not, whatever technique is applied to it. Two numbers are therefore reported together, never one: the ratio and the count.
counts = df["status"].value_counts()
print(counts)
print("Prevalence :", round(counts["Fraud"] / counts.sum(), 5))
print("Imbalance ratio :", round(counts.max() / counts.min(), 1), ": 1")
print("Majority share :", round(100 * counts.max() / counts.sum(), 2), "%")
print("Minority share :", round(100 * counts.min() / counts.sum(), 2), "%")status
Normal 119652
Fraud 348
Name: count, dtype: int64
Prevalence : 0.0029
Imbalance ratio : 343.8 : 1
Majority share : 99.71 %
Minority share : 0.29 %Interpretation. The majority class is Normal with 99.71% of the
observations, the minority class is Fraud with 0.29%, that is, close to 344
normal transactions for every fraudulent one. The absolute count of 348 frauds is
low: after an 80/20 split the test set holds only 70 of them, which makes any
metric computed on that class unstable — one fraud more or less caught moves
recall by 1.4 points — and makes a stratified split mandatory (chapter 027).
Recommendation. value_counts() on the target is the first command run after
loading a classification dataset, before any exploration and before any modeling
decision. It conditions the choice of metrics (chapter 075), the splitting
strategy (chapter 027) and the treatment of imbalance (chapter 051).
| Domain | Typical prevalence of the positive class |
|---|---|
| Telecom churn | 15% to 30% |
| Credit default | 2% to 8% |
| Card fraud | 0.1% to 0.5% |
| Network intrusion detection | below 0.1% |
| Rare-disease screening | 0.01% to 1% |
Balanced classes are the exception. Imbalance is the normal situation in applied classification, and a course that treats it as a special case teaches the wrong default.
| Criterion | Positive class | Minority class |
|---|---|---|
| How it is established | Decided during framing | Measured by value_counts() |
| Can it change without the data changing | Yes, if the business question changes | No |
| Can it change without the question changing | No | Yes, after resampling or a new extraction |
| Where it is recorded | In the project documentation and in one line of code | In the dataset |
The dominant configuration pairs the two, and the pairing is so common that the words get used interchangeably. Two situations break it.
A majority positive class. A quality-control process in which 92% of the parts are conforming, and whose purpose is to certify conformity rather than to catch defects, legitimately designates the majority as positive. The action triggered — releasing the batch — attaches to conformity.
A positive class that stops being the minority. After rebalancing the training set, the positive class may be half the training rows and remain 0.29% of the test rows. Both statements are true at the same time, of different sets. This is exactly why "the minority class" must never be used as a shorthand for "the positive class" in a report: the referent changes between the training section and the evaluation section.
from sklearn.dummy import DummyClassifier
baseline = DummyClassifier(strategy="most_frequent").fit(X_train, y_train)
y_base = baseline.predict(X_test)
print("Accuracy :", round(accuracy_score(y_test, y_base), 4))
print("Recall Fraud :", round(recall_score(y_test, y_base, pos_label=1), 4))
print("ROC AUC :", round(roc_auc_score(y_test, baseline.predict_proba(X_test)[:, 1]), 4))
print("Frauds caught :", int(((y_base == 1) & (y_test == 1)).sum()), "out of", int(y_test.sum()))Accuracy : 0.9971
Recall Fraud : 0.0
ROC AUC : 0.5
Frauds caught : 0 out of 70Interpretation. A model that always answers Normal reaches 99.71% accuracy
and detects not a single fraud. It is perfectly useless and perfectly well
scored. That is why accuracy is set aside as soon as the imbalance is marked
(chapters 053 and 060), and why the AUC of 0.5 is the honest summary of this
model: it ranks nothing.
The baseline is mandatory, not optional. DummyClassifier is not a teaching
device. It is the reference point every project owes itself: a model that does
not beat it has learned nothing, whatever its accuracy says. Compare the
trained model of section 5.2 against it on the figures that matter.
| Model | Accuracy | Recall on Fraud | Frauds caught out of 70 | Alerts raised |
|---|---|---|---|---|
DummyClassifier(strategy="most_frequent") | 0.9971 | 0.0000 | 0 | 0 |
| Random forest, threshold 0.02 | 0.9848 | 0.6286 | 44 | 383 |
How to read this table. The useful model has the lower accuracy of the two. Any selection procedure driven by accuracy on this problem would retain the model that catches nothing. The choice of metric is not a reporting detail; it decides which model ships (chapters 050, 051 and 075).
X.shape[0] == y.shape[0] is a necessary condition and never a sufficient one. A
sort, a shuffle or a reindex applied to one of the two objects preserves the
lengths and destroys the correspondence, silently. Estimators convert pandas
objects to NumPy arrays and ignore the index, so nothing downstream can detect
it. The only symptom is a performance figure sitting at chance level.
Correct formulation : "The invariant to preserve is the positional correspondence: row i of X and the value describe the same observation. Any filtering or sorting is applied to the source table, before the split."
LabelEncoder sorts the levels lexicographically. With Fraud and Normal,
fraud receives the code 0, and the metrics, which default to pos_label=1, then
measure the Normal class. Nothing warns, and every figure in the report is
computed on the wrong event.
Correct formulation : "The positive class is declared explicitly, for example
y = (df["status"] == "Fraud").astype(int), and verified by reading classes_
after fitting."
"Positive" means "presence of the condition being looked for". The term is
descriptive, not evaluative. The positive class of a screening model is
Affected, that of a churn model is Churned, that of a default model is
Default. None of the three is good news.
Correct formulation : "The positive class is the event the model exists to detect, independently of whether that event is desirable."
Precision, recall, F1-score and PR-AUC are asymmetric. On the same model, on the
same predictions, the F1-score is 0.194 for Fraud and 0.992 for Normal. A
figure quoted without its reference class cannot be checked, challenged or
compared against anything.
Correct formulation : "The F1-score on the Fraud class, at threshold 0.02,
is 0.194", and never "the model's F1-score is 0.194".
The positive class results from a methodological decision; the minority class results from a count. Their coincidence is an empirical fact about most use cases, not a definition. After rebalancing, the positive class is no longer the minority in the training set and remains the minority in the test set.
Correct formulation : "The positive class is designated, the minority class is observed."
An AUC clearly under 0.5 means the ranking is being scored in the wrong
direction: the column of predict_proba that was read corresponds to the other
class, or the label encoding is inverted relative to the metric's pos_label. A
model that had genuinely learned the phenomenon backwards would be, by the same
token, an excellent model once its output was negated — which is not what is
happening.
Correct formulation : "The AUC of 0.122 indicates an inverted score column.
classes_ reads ['Fraud' 'Normal'], so column 1 carries the probability of
Normal; scored on the correct column, the AUC is 0.878."
The ground truth is a measurement produced by a fallible chain: detection,
annotation, recording. Undetected frauds carry the label Normal and are
indistinguishable from genuine normal transactions. A measured recall of 0.63 is
recall against the frauds that were found, not against the frauds that
occurred.
Correct formulation : "The model recovers 63% of the frauds identified by the existing detection chain. Its performance against undetected fraud cannot be measured with these labels."
NOTATION
X matrix of explanatory variables shape (n, p) UPPERCASE
y target vector shape (n,) lowercase
Origin: the typography of linear algebra
matrix = uppercase, vector = lowercase, scalar = italic
Dimensional coherence: (XT X)-1 XT y is defined only if X is a
matrix and y a vector of length n.
THE ALIGNMENT INVARIANT
Necessary : X.shape[0] == y.shape[0] checked by the library
Sufficient: row i of X and y_i describe the SAME observation
checked by no library at all
Loud dropna, filtering -> ValueError, benign
Silent sort_values, sample, -> no exception, AUC at chance
one-sided reset_index
Rule: reorder the source table, before the split. Never after.
LABEL AND CLASS
Label: the value of the target variable for one observation.
classification -> class label, categorical
regression -> target value, numeric
Class: a level of the target; the classes form a partition,
mutually exclusive and collectively exhaustive.
Ground truth is a measurement, not the phenomenon.
Inter-annotator agreement bounds attainable performance;
Cohen's kappa (1960) corrects raw agreement for chance.
ENCODING
LabelEncoder sorts lexicographically: "Fraud" -> 0, "Normal" -> 1.
Declare the encoding instead:
y = (df["status"] == "Fraud").astype(int)
predict_proba columns follow estimator.classes_ .
An AUC well below 0.5 means an inverted column, not a bad model.
POSITIVE CLASS
DESIGNATED, not observed.
Criterion: the event you are trying to detect,
the one that triggers an action.
Positive = presence of the phenomenon, never "favorable".
It determines precision, recall, F1, PR-AUC and the reading
of the confusion matrix.
Invariant to inversion: accuracy, balanced accuracy, log loss,
Matthews coefficient, Cohen's kappa.
MAJORITY / MINORITY CLASS
OBSERVED by counting: value_counts()
Prevalence = n_positive / n Ratio = n_maj / n_min
The ABSOLUTE count of the minority class matters as much
as the ratio. Report both.
DummyClassifier(strategy="most_frequent") is the mandatory
baseline: on this dataset it scores 0.9971 accuracy and
catches 0 frauds out of 70.Summary statement
Xis uppercase because it is a matrix of shape (n, p) andyis lowercase because it is a vector of shape (n,), their correspondence being positional and never guaranteed by the equality of their lengths alone; a label is the value of the target variable recorded for one observation and a class is a level of that variable; and the positive class is the one the organization is trying to detect — a methodological designation, distinct from the empirical observation of minority status, on which the reading of the confusion matrix and every asymmetric metric depend.
Associated quizzes
007.1-quiz-x-y-notation.md007.2-quiz-shapes-and-alignment.md007.3-quiz-labels-and-ground-truth.md007.4-quiz-classes-and-encoding.md007.5-quiz-positive-class.md007.6-quiz-majority-minority-classes.mdNext chapter : 008.0-algorithm-vs-model.md