Code
import numpy as np
import pandas as pd
import crabbymetrics as cm
np.set_printoptions(precision=4, suppress=True)Latent indices, mean-scale predictions, and labels after the MLE overhaul
This vignette documents the prediction contract used by the nonlinear MLE-style estimators after the 0.7 overhaul.
For the estimators where prediction makes sense, the public surface is now intentionally layered:
predict_lin(...): the latent linear index;predict(...): the mean-scale or model-implied prediction object;predict_label(...): only for classifiers, where hard labels are genuinely distinct from probabilities.That contract avoids overloading predict() to sometimes mean probabilities and sometimes mean labels.
rng = np.random.default_rng(20260525)
n = 1200
x = rng.normal(size=(n, 3))
eta_true = -0.15 + x @ np.array([0.7, -0.45, 0.25])
p_true = 1.0 / (1.0 + np.exp(-eta_true))
y = rng.binomial(1, p_true).astype(np.int32)
logit = cm.Logit(max_iterations=300, gradient_tolerance=1e-8)
logit.fit(x, y)
check = x[:8]
logit_table = pd.DataFrame(
{
"eta": logit.predict_lin(check),
"prob": logit.predict(check),
"label@0.50": logit.predict_label(check),
"label@0.35": logit.predict_label(check, cutoff=0.35),
}
)
logit_table| eta | prob | label@0.50 | label@0.35 | |
|---|---|---|---|---|
| 0 | -0.496600 | 0.378340 | 0 | 1 |
| 1 | -1.241933 | 0.224100 | 0 | 0 |
| 2 | 0.368289 | 0.591046 | 1 | 1 |
| 3 | -0.501098 | 0.377283 | 0 | 1 |
| 4 | -0.061057 | 0.484741 | 0 | 1 |
| 5 | 0.326070 | 0.580803 | 1 | 1 |
| 6 | 0.088783 | 0.522181 | 1 | 1 |
| 7 | 0.761984 | 0.681784 | 1 | 1 |
The identity is exact by construction:
For multinomial logit, predict_lin() returns one classwise score per row, predict() returns class probabilities, and predict_label() returns the argmax class.
rng = np.random.default_rng(20260526)
n = 1500
coef = np.array(
[
[0.8, -0.2, 0.15],
[-0.1, 0.65, -0.35],
[0.25, -0.4, 0.45],
]
)
intercept = np.array([0.25, -0.15, 0.05])
x = rng.normal(size=(n, coef.shape[1]))
logits = x @ coef.T + intercept
logits = logits - logits.max(axis=1, keepdims=True)
probs = np.exp(logits)
probs = probs / probs.sum(axis=1, keepdims=True)
y = np.array([rng.choice(coef.shape[0], p=probs[i]) for i in range(n)], dtype=np.int32)
mlogit = cm.MultinomialLogit(max_iterations=300, gradient_tolerance=1e-8)
mlogit.fit(x, y)
check = x[:6]
eta_hat = mlogit.predict_lin(check)
prob_hat = mlogit.predict(check)
label_hat = mlogit.predict_label(check)
prob_table = pd.DataFrame(prob_hat, columns=["class0", "class1", "class2"])
prob_table["label"] = label_hat
prob_table| class0 | class1 | class2 | label | |
|---|---|---|---|---|
| 0 | 0.262071 | 0.583904 | 0.154025 | 1 |
| 1 | 0.316268 | 0.145136 | 0.538597 | 2 |
| 2 | 0.493538 | 0.365184 | 0.141278 | 0 |
| 3 | 0.369470 | 0.328834 | 0.301696 | 0 |
| 4 | 0.423319 | 0.165598 | 0.411083 | 0 |
| 5 | 0.239490 | 0.683380 | 0.077130 | 1 |
For Poisson, the latent index is the log conditional mean, and predict() exponentiates it back to the count scale.
rng = np.random.default_rng(20260527)
n = 900
x = rng.normal(size=(n, 2))
mu_true = np.exp(0.2 + x @ np.array([0.35, -0.3]))
y = rng.poisson(mu_true).astype(float)
poisson = cm.Poisson(max_iterations=250, tolerance=1e-8)
poisson.fit(x, y)
check = x[:8]
poisson_table = pd.DataFrame(
{
"log_mu": poisson.predict_lin(check),
"mu": poisson.predict(check),
}
)
poisson_table| log_mu | mu | |
|---|---|---|
| 0 | 0.400609 | 1.492733 |
| 1 | 0.031761 | 1.032271 |
| 2 | -0.337352 | 0.713657 |
| 3 | -0.420010 | 0.657040 |
| 4 | 0.541645 | 1.718832 |
| 5 | 0.247413 | 1.280707 |
| 6 | 0.143784 | 1.154635 |
| 7 | 0.530882 | 1.700431 |
The new split keeps three conceptually different objects separate:
That makes calibration work, diagnostics, and downstream scoring much cleaner than a classifier API where predict() skips straight to labels.