Skip to content
Skip to lesson
Back to Topics
LearnML Algorithms & EvaluationLogistic Regression and Metrics
🏛️MediumModel Architecture

Logistic Regression and Metrics

REQ-10234 needs a review decision. A sigmoid score, a dollar-cost threshold, and a calibration check turn eight access rows into an action you can audit.

48 min read
Learning path
Step 33 of 199 in the full curriculum

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

Access-change request REQ-10234 is sitting in the queue. May the workflow auto-process a permission change under policy line P-7, or must a human review it first?

The engine has to choose before the case is closed, so the true outcome isn't available yet. It needs a score that orders risk and a rule that turns that score into an action.

The previous chapter fitted latency from retrieved-context length with a design matrix, a loss, and a gradient-descent loop. We reuse that machinery, but now predict whether a request requires review. The score estimates a label; a separate policy chooses an action.

maps two evidence features to a score in (0, 1), then asks you to justify treating that score as a probability.[1]

Eight constructed requests are enough to derive the and check it in NumPy against scikit-learn.[2][3] A second slice, never used for fitting, is where a threshold's cost, the ranking, and the frequencies come apart. These rows teach the arithmetic. They aren't evidence for releasing a permission workflow.

From a latency number to a review decision

The previous target was continuous: "how many milliseconds will this evidence cost?" A residual of 2 ms still meant something. Now the label is binary: 1 means an access-change request needs human review before any permission change; 0 means policy evidence supports automatic handling.

For this fictional cost model, an incorrect automatic approval costs $120 in permission and investigation. An unnecessary review costs $18 of queue time. Correct decisions have zero incremental cost. Those assumptions will choose the threshold later, not the loss used to fit the model. A real permission workflow must still enforce its authorization rules independently of this classifier.

Ordinary least squares can fit 0/1 labels as a linear probability model, but it has limitations:

  1. Unbounded predictions: y^=w⊤x+b\hat y=w^\top x+b can fall outside [0, 1]. Values such as -0.2 aren't valid probabilities.
  2. Sensitivity to influential rows: unusual feature values and residuals can change the fitted boundary. The direction depends on the data; an extreme positive doesn't universally move a threshold rightward.
  3. Conditional variance: for Bernoulli outcomes, Var⁡(Y∣x)=p(x)(1−p(x))\operatorname{Var}(Y\mid x)=p(x)(1-p(x)), which can vary with features. Constant variance isn't required to compute OLS or for unbiasedness under a correct conditional mean, but it is part of the classical BLUE guarantee and conventional standard-error formulas.

Squared error on bounded probabilities is also a legitimate scoring loss, the Brier score. Logistic regression gives us a Bernoulli likelihood with a linear logit; it isn't the only way to estimate probabilities.

A linear score can fall outside (0, 1), and a value such as -0.2 isn't a probability. Which smooth function keeps every score inside that interval and still has a derivative a training step can follow? The same split shows up in a or a router: the model emits a score, and a separate threshold turns that score into an action.

Squeeze the score into (0, 1)

Before choosing a threshold, we need a score that behaves like a probability: it must stay between 0 and 1, move upward when evidence favors review, and change smoothly enough for gradient descent. The sigmoid (or logistic) function does that:

σ(z)=11+e−z\sigma(z) = \frac{1}{1 + e^{-z}}

Four properties matter for the later gradient:

  • As z→+∞z \to +\infty, σ(z)→1\sigma(z) \to 1
  • As z→−∞z \to -\infty, σ(z)→0\sigma(z) \to 0
  • At z=0z = 0, σ(0)=0.5\sigma(0) = 0.5
  • Its derivative is σ′(z)=σ(z)⋅(1−σ(z))\sigma'(z) = \sigma(z) \cdot (1 - \sigma(z)), which is positive for finite zz and peaks at 0.250.25 when z=0z = 0. That tidy form is why the gradient of log-loss later collapses so cleanly.

Predict the shape before calculating. A large negative score should look almost like zero, z = 0 should land at one half, and a large positive score should approach one. Then compute five concrete values by hand:

ze^{-z}1 + e^{-z}σ(z)
-4.054.655.60.018
-1.02.7183.7180.269
0.01.02.00.500
1.00.3681.3680.731
4.00.0181.0180.982
sigmoid-probabilities.py
import numpy as np

def sigmoid(z: np.ndarray) -> np.ndarray:
    return 1.0 / (1.0 + np.exp(-z))

scores = np.array([-4.0, -1.0, 0.0, 1.0, 4.0])
print("scores:", scores)
print("probabilities:", sigmoid(scores).round(3))
Output
scores: [-4. -1.  0.  1.  4.]
probabilities: [0.018 0.269 0.5   0.731 0.982]

The code confirms the hand calculation. The curve is steep near zero and flat in both tails, which will matter when an update tries to move an already confident request.

The sigmoid curve maps scores from minus six to six into probabilities from zero to one. Its derivative peaks at 0.25 at score zero. Equal two-point score changes move probability by 0.462 near the center and 0.016 in the positive tail.
The derivative peaks at `0.25` when `z = 0`, so a two-point score move near the center changes probability by `0.462`. The same score move in the positive tail changes it by only `0.016`.

The curve saturates quickly. A score of +4 produces p = 0.982; whether that number represents an honest frequency still needs validation on labels the fit did not see.

For finite real logits, sigmoid is strictly between 0 and 1 mathematically. Floating-point evaluation can round extreme results to 0 or 1. The first cell uses small logits for inspection; the trainer below uses a stable implementation and computes loss directly from logits.

The model remains linear inside the sigmoid:

z=w0+w1x1+w2x2=wTxz = w_0 + w_1 x_1 + w_2 x_2 = w^T x

The probability is p=σ(z)p = \sigma(z). The positive class is needs human review, so the sign and size of zz carry the evidence before the sigmoid bounds it.

The connection comes from odds, the ratio of the positive-label probability to the negative-label probability. An odds ratio would compare two odds, for example at two feature settings. Here the events are review required versus review not required, not the action the threshold will take:

odds=p1−p∈(0,∞)\text{odds} = \frac{p}{1 - p} \in (0, \infty)

When p=0.8p = 0.8, the odds are 0.8/0.2=40.8 / 0.2 = 4 (4 to 1 in favor of review). Taking the natural logarithm produces the , or :[1]

z=logit⁡(p)=log⁡(p1−p)∈(−∞,∞)z = \operatorname{logit}(p) = \log\left(\frac{p}{1 - p}\right) \in (-\infty, \infty)

Modeling the log-odds as a linear function z=wTxz = w^T x is the logit link function. Inverting that link gives the sigmoid activation directly:

p1−p=ez  ⟹  p=ez(1−p)  ⟹  p(1+ez)=ez  ⟹  p=ez1+ez=11+e−z=σ(z)\frac{p}{1 - p} = e^z \implies p = e^z(1 - p) \implies p(1 + e^z) = e^z \implies p = \frac{e^z}{1 + e^z} = \frac{1}{1 + e^{-z}} = \sigma(z)

At z=0z = 0, the odds are 1:1 and p=0.5p = 0.5. Adding one unit to zz multiplies the odds by e≈2.718e \approx 2.718.

The linear decision boundary hyperplane

Use b=w0b=w_0 and β=[w1,w2]⊤\beta=[w_1,w_2]^\top for the feature coefficients, keeping the intercept separate. At t=0.5t=0.5, review is selected when z=b+β⊤x≥0z=b+\beta^\top x\ge0. The equal-score boundary is

β⊤x+b=0  ⟺  w0+w1x1+w2x2=0\beta^\top x+b=0 \quad \iff \quad w_0+w_1x_1+w_2x_2=0

For w2≠0w_2\ne0, we can write this line as:

x2=−w1w2x1−w0w2x_2 = -\frac{w_1}{w_2} x_1 - \frac{w_0}{w_2}

For nonzero β\beta, it is the normal vector pointing toward p>0.5p>0.5. The signed orthogonal distance in these feature coordinates is:

dist⁡(x)=β⊤x+b∥β∥2=z∥β∥2\operatorname{dist}(x)=\frac{\beta^\top x+b}{\|\beta\|_2}=\frac{z}{\|\beta\|_2}

Points sitting far from the hyperplane carry large ∣z∣|z|, driving σ(z)\sigma(z) into the saturated tails near 0 or 1. Changing the decision threshold to any t∈(0,1)t \in (0, 1) shifts the decision boundary to the parallel hyperplane:

β⊤x+b=logit⁡(t)=log⁡(t1−t)\beta^\top x+b=\operatorname{logit}(t)=\log\left(\frac{t}{1-t}\right)

Lowering the threshold to t=0.20t = 0.20 requires only z≥log⁡(0.25)≈−1.386z \ge \log(0.25) \approx -1.386, shifting the decision plane into negative logit space to route more borderline requests to human review.

If w2=0w_2=0 but w1≠0w_1\ne0, the line is vertical. If β=0\beta=0, the classifier is constant and has no separating hyperplane. Distance depends on feature units, so don't interpret it as a physical risk measure.

Eight historical requests

Each historical request has two scaled features produced from retrieved evidence. Read them as competing signals, not as finished decisions:

  • x1 = ambiguity score: conflicting audit logs, missing requester context, or unclear scope description
  • x2 = auto-policy support score: strength of evidence that policy line P-7 permits automatic handling

The label y = 1 when the completed case required human review before a permission change, and y = 0 when automatic handling was acceptable. The model must learn how those two signals combine.

These eight rows are constructed training data; the validation rows introduced later are separate:

Requestx1 ambiguityx2 auto-policy supporty needs review
R11.010
R21.520
R32.031
R43.561
R54.071
R64.551
R72.840
R83.280

The training stays small so you can verify each number. Before seeing any weights, predict their signs: ambiguity should raise review risk, while policy support should lower it. R3 needed review despite only moderate ambiguity. R8 had substantial ambiguity, but strong policy support made automatic processing acceptable.

Add the intercept column of ones and build the design X (8 × 3) and the label vector y.

Log-loss (binary cross-entropy) is the right loss for probabilities

If a request didn't need review (y = 0), a score of p = 0.93 gives the observed outcome only 1 - 0.93 = 0.07 probability. Its negative log is -log(0.07) ≈ 2.659. A score of p = 0.07 instead assigns that outcome probability 0.93, for a much smaller loss, -log(0.93) ≈ 0.073. A confident wrong prediction pays heavily.

Deriving binary cross-entropy from maximum likelihood

Each historical request has a binary label yi∈{0,1}y_i \in \{0, 1\}. We model yiy_i given features xix_i as a Bernoulli random trial with success probability pi=P(Yi=1∣xi)=σ(wTxi)p_i = P(Y_i = 1 \mid x_i) = \sigma(w^T x_i). The Bernoulli probability mass function for a single example is:

P(Yi=yi∣xi)=piyi(1−pi)1−yiP(Y_i = y_i \mid x_i) = p_i^{y_i} (1 - p_i)^{1 - y_i}

When yi=1y_i = 1, (1−pi)0=1(1 - p_i)^0 = 1 leaving pip_i. When yi=0y_i = 0, pi0=1p_i^0 = 1 leaving 1−pi1 - p_i. Assuming conditional independence across the NN requests in our training set, the likelihood of observing the entire dataset is the joint product:

L(w)=∏i=1NP(Yi=yi∣xi)=∏i=1Npiyi(1−pi)1−yi\mathcal{L}(w) = \prod_{i=1}^N P(Y_i = y_i \mid x_i) = \prod_{i=1}^N p_i^{y_i} (1 - p_i)^{1 - y_i}

Multiplying hundreds of probabilities in (0,1)(0, 1) swiftly triggers floating-point underflow. Taking the natural logarithm converts the product into an additive sum of log-probabilities:

log⁡L(w)=∑i=1N[yilog⁡pi+(1−yi)log⁡(1−pi)]\log \mathcal{L}(w) = \sum_{i=1}^N \left[ y_i \log p_i + (1 - y_i) \log(1 - p_i) \right]

Maximizing this likelihood is equivalent to minimizing the Negative Log-Likelihood (NLL). Normalizing by NN gives the empirical Binary Cross-Entropy (BCE) loss:[4][5]

L(w)=−1N∑i=1N[yilog⁡pi+(1−yi)log⁡(1−pi)]L(w) = -\frac{1}{N} \sum_{i=1}^N \left[ y_i \log p_i + (1 - y_i) \log(1 - p_i) \right]

For one hard label, define its target distribution qi=(1−yi,yi)q_i=(1-y_i,y_i) and predicted distribution ri=(1−pi,pi)r_i=(1-p_i,p_i). Then H(qi,ri)=H(qi)+DKL(qi∥ri)H(q_i,r_i)=H(q_i)+D_{\mathrm{KL}}(q_i\parallel r_i) and H(qi)=0H(q_i)=0 because this per-row target is a point mass. That doesn't make the population label distribution deterministic: our balanced training labels have nonzero marginal entropy. A sampled label isn't the full true conditional distribution. Expected log loss is minimized by that true conditional probability when the model can represent it.

Why not reuse Mean Squared Error?

We can fit with squared error, but composing it with sigmoid changes the optimization:

  1. Nonconvexity in weights: squared error after sigmoid is generally nonconvex. BCE with a linear logit is convex. Its Hessian is X⊤diag⁡(pi(1−pi))X/NX^\top\operatorname{diag}(p_i(1-p_i))X/N, positive definite at finite weights when XX has full column rank; redundant columns instead give flat directions.
  2. Small gradients on confident mistakes: for squared error (p−y)2(p-y)^2, the logit derivative is 2(p−y)p(1−p)2(p-y)p(1-p). For half-squared error it is (p−y)p(1−p)(p-y)p(1-p). At y=1,z=−8y=1,z=-8, the former is about −0.000670-0.000670, while BCE's derivative is about −0.999665-0.999665. BCE preserves a strong learning signal on this confidently wrong prediction.

Strict convexity permits at most one finite minimizer. With complete or quasi-complete class separation, an unregularized optimum can require coefficients diverging to infinity. On perfectly separable data, loss can approach zero without attaining it at finite weights. Regularization can constrain that growth. Future accuracy still requires evaluation on representative unseen data.

Work one row by hand. For R4 (x1=3.5, x2=6, y=1) with a starting guess w = [0, 0, 0]:

  • z = 0 + 0·3.5 + 0·6 = 0
  • p = σ(0) = 0.5
  • L = -[1 * log(0.5) + 0] = -log(0.5) ≈ 0.693

Now suppose the model improves in the correct direction and predicts p=0.82p = 0.82 for the same row:

  • L = -log(0.82) ≈ 0.198
  • The loss dropped because the model became more confident in the correct direction, which is the behavior you want.

For code, compute the same loss from logits rather than taking log(sigmoid(z)). The expression log(1 + exp(z)) - y*z is the same loss, but evaluating exp(z) directly can overflow. NumPy's logaddexp(0, z) - y*z evaluates that softplus term stably even when z is very large in either direction.

The next check keeps the arithmetic in logit space. Predict which row should be cheap, which should be expensive, and whether extreme logits remain finite.

stable-log-loss-from-logits.py
import numpy as np

def log_loss_from_logits(y: np.ndarray, z: np.ndarray) -> float:
    return float(np.mean(np.logaddexp(0.0, z) - y * z))

print("correct positive:", round(log_loss_from_logits(np.array([1.0]), np.array([2.0])), 3))
print("confident wrong:", round(log_loss_from_logits(np.array([1.0]), np.array([-4.0])), 3))
extreme = log_loss_from_logits(np.array([0.0, 1.0]), np.array([1000.0, -1000.0]))
print("extreme logits finite:", np.isfinite(extreme))
Output
correct positive: 0.127
confident wrong: 4.018
extreme logits finite: True

The log-loss gradient collapses to (p - y) · x

The loss tells us how wrong a prediction is. The gradient tells each weight how to change the score. For R4, the starting probability error is 0.5 - 1 = -0.5. Multiplying by [1, 3.5, 6] gives [-0.5, -1.75, -3.0]. Subtracting this gradient raises the next score for that row. Why does this simple multiplication give the derivative?

Chain rule step by step

We have L(p,y)L(p, y) with p=σ(z)p = \sigma(z) and linear score z=wTx=∑kwkxkz = w^T x = \sum_k w_k x_k:

∂L∂wj=∂L∂p⋅∂p∂z⋅∂z∂wj\frac{\partial L}{\partial w_j} = \frac{\partial L}{\partial p} \cdot \frac{\partial p}{\partial z} \cdot \frac{\partial z}{\partial w_j}
  1. Loss with respect to probability: ∂L∂p=−yp+1−y1−p=−y(1−p)+(1−y)pp(1−p)=p−yp(1−p)\frac{\partial L}{\partial p} = -\frac{y}{p} + \frac{1 - y}{1 - p} = \frac{-y(1 - p) + (1 - y)p}{p(1 - p)} = \frac{p - y}{p(1 - p)}
  2. Probability with respect to score: ∂p∂z=σ′(z)=σ(z)(1−σ(z))=p(1−p)\frac{\partial p}{\partial z} = \sigma'(z) = \sigma(z)(1 - \sigma(z)) = p(1 - p)
  3. Score with respect to weight: ∂z∂wj=xj\frac{\partial z}{\partial w_j} = x_j

Multiplying the first two terms produces a cancellation: the sigmoid derivative in the numerator eliminates the p(1−p)p(1 - p) denominator from the cross-entropy derivative:

∂L∂z=(p−yp(1−p))⋅p(1−p)=p−y\frac{\partial L}{\partial z} = \left(\frac{p - y}{p(1 - p)}\right) \cdot p(1 - p) = p - y

Compare the two label cases directly:

LabelLoss LLDerivative with respect to score zzInterpretation
y=1y = 1−log⁡(p)-\log(p)p−1p - 1Prediction is too low unless pp is near 1; gradient pushes zz upward
y=0y = 0−log⁡(1−p)-\log(1-p)ppPrediction is too high unless pp is near 0; gradient pushes zz downward

Both cases yield ∂L/∂z=p−y\partial L / \partial z = p - y. Combining this with ∂z/∂wj=xj\partial z / \partial w_j = x_j gives the single-sample weight gradient:

∂L∂wj=(p−y)⋅xj\frac{\partial L}{\partial w_j} = (p - y) \cdot x_j

Across the entire dataset of NN samples, stacking rows into design matrix X∈RN×dX \in \mathbb{R}^{N \times d}, predicted probabilities y^=σ(Xw)∈RN\hat{y} = \sigma(Xw) \in \mathbb{R}^N, and label vector y∈RNy \in \mathbb{R}^N gives the batch gradient:[4][5]

∇wL=1NXT(y^−y)\nabla_w L = \frac{1}{N} X^T (\hat{y} - y)

The bridge to linear regression

Notice the mathematical unity between this classifier and the linear regression model:

  • Linear regression (MSE): ∇wL=2NXT(y^lin−y)\nabla_w L = \frac{2}{N} X^T (\hat{y}_{\text{lin}} - y), where y^lin=Xw\hat{y}_{\text{lin}} = Xw
  • Logistic regression (BCE): ∇wL=1NXT(y^log−y)\nabla_w L = \frac{1}{N} X^T (\hat{y}_{\text{log}} - y), where y^log=σ(Xw)\hat{y}_{\text{log}} = \sigma(Xw)

Both use features transposed against prediction errors. The factor of 2 differs because the previous lesson used MSE; using half-MSE would make the prefactors match. Loss normalization changes gradient magnitudes and therefore the learning rate needed for the same update.

The logit is the Bernoulli canonical link in generalized linear models. The score-error term is simple, but weight updates still depend on feature values, averaging, step size, and any penalties or sample weights.

Plug in numbers for the starting point on R4 (p=0.5, y=1, x=[1, 3.5, 6]):

  • gradw0=(0.5−1)⋅1=−0.5\text{grad}_{w0} = (0.5 - 1) \cdot 1 = -0.5
  • gradw1=(0.5−1)⋅3.5=−1.75\text{grad}_{w1} = (0.5 - 1) \cdot 3.5 = -1.75
  • gradw2=(0.5−1)⋅6=−3.0\text{grad}_{w2} = (0.5 - 1) \cdot 6 = -3.0

The gradient points uphill, toward increasing loss. Gradient descent subtracts it, scaled by the , to move downhill.

The sign is the learning signal. If y=1y=1 and p=0.5p=0.5, p−yp-y is negative, so subtracting the gradient increases weights attached to positive features and raises the future score for similar requests. If y=0y=0 and p=0.8p=0.8, p−yp-y is positive, so subtracting the gradient lowers those weights and reduces the score.

Repeat that arithmetic for each row and average the gradients. You now have one logistic-regression update. The code keeps the linear-regression loop and swaps in the sigmoid prediction and (p - y) * x gradient.

check-gradient-with-finite-differences.py
import numpy as np

def sigmoid(z: float) -> float:
    return float(1.0 / (1.0 + np.exp(-z)))

def loss(w: np.ndarray, x: np.ndarray, y: float) -> float:
    z = x @ w
    return float(np.logaddexp(0.0, z) - y * z)

x = np.array([1.0, 3.5, 6.0])
y = 1.0
w = np.zeros(3)
analytic = (sigmoid(x @ w) - y) * x
eps = 1e-6
numeric = np.array([
    (loss(w + eps * np.eye(3)[j], x, y) - loss(w - eps * np.eye(3)[j], x, y)) / (2 * eps)
    for j in range(3)
])
print("analytic:", analytic.round(3))
print("numeric :", numeric.round(3))
print("match:", np.allclose(analytic, numeric, atol=1e-6))
Output
analytic: [-0.5  -1.75 -3.  ]
numeric : [-0.5  -1.75 -3.  ]
match: True
Eight training rows contribute signed gradients (p minus y) times each feature. Their column means are zero, minus 0.344, and minus 0.375; subtracting a 0.2 learning-rate step gives first weights 0, 0.069, and 0.075.
At zero weights, every request starts at `p = 0.5`. Multiplying each signed error by its feature creates the row heatmap; column averages give `g = [0, -0.344, -0.375]`, so the first `lr = 0.2` update becomes `w = [0, 0.069, 0.075]`.

Fit the eight rows in NumPy

Start with the full eight-row fit. The loop is the gradient-descent trainer from the latency chapter, with a sigmoid prediction and a (p - y) * x gradient. Before running it, predict the first update: the positive feature weights should rise from zero, while the intercept should stay near zero because the batch starts with equal positive and negative pressure.

logistic-regression-numpy.py
import numpy as np

def sigmoid(z: np.ndarray) -> np.ndarray:
    # exp(-log(1 + exp(-z))) avoids overflow without clipping the score.
    return np.exp(-np.logaddexp(0.0, -z))

def log_loss_from_logits(y_true: np.ndarray, z: np.ndarray) -> float:
    if z.ndim != 1 or y_true.shape != z.shape:
        raise ValueError("labels and logits must both have shape (n,)")
    return float(np.mean(np.logaddexp(0.0, z) - y_true * z))

def fit_logistic(X: np.ndarray, y: np.ndarray, lr: float = 0.2, epochs: int = 3000, verbose: bool = True) -> np.ndarray:
    if X.ndim != 2 or len(X) == 0 or y.shape != (len(X),):
        raise ValueError("X must have shape (n, d), and y must have shape (n,), with n > 0")
    if not np.isfinite(X).all() or not np.isin(y, [0, 1]).all():
        raise ValueError("features must be finite and labels must be 0 or 1")
    n, d = X.shape
    w = np.zeros(d)
    for epoch in range(epochs):
        z = X @ w
        p = sigmoid(z)
        grad = (1 / n) * (X.T @ (p - y))
        w -= lr * grad
        if verbose and epoch in (0, 200, 500, 1000, 2999):
            loss = log_loss_from_logits(y, X @ w)
            print(f"after_epoch={epoch + 1:4d}  loss={loss:.4f}  w={np.round(w, 3)}")
    return w

def predict_proba(X: np.ndarray, w: np.ndarray) -> np.ndarray:
    return sigmoid(X @ w)

# Historical access-change requests: [ambiguity, auto-policy support]
X_raw = np.array([
    [1.0, 1.0], [1.5, 2.0], [2.0, 3.0], [3.5, 6.0],
    [4.0, 7.0], [4.5, 5.0], [2.8, 4.0], [3.2, 8.0],
])
y = np.array([0, 0, 1, 1, 1, 1, 0, 0])

X = np.c_[np.ones((len(X_raw), 1)), X_raw]   # add intercept column

w = fit_logistic(X, y, lr=0.2, epochs=3000)
p = predict_proba(X, w)
print("\nFinal weights (w0, w1, w2):", w.round(3))
print("Probabilities:", p.round(3))
print("weights_match_reference", np.allclose(w, [-4.894, 3.399, -0.959], atol=0.01))
Output
after_epoch=   1  loss=0.6830  w=[0.    0.069 0.075]
after_epoch= 201  loss=0.4648  w=[-2.205  1.615 -0.462]
after_epoch= 501  loss=0.4206  w=[-3.524  2.452 -0.686]
after_epoch=1001  loss=0.4092  w=[-4.356  3.023 -0.85 ]
after_epoch=3000  loss=0.4072  w=[-4.894  3.399 -0.959]

Final weights (w0, w1, w2): [-4.894  3.399 -0.959]
Probabilities: [0.079 0.153 0.274 0.777 0.88  0.996 0.687 0.156]
weights_match_reference True

Keep the label vector one-dimensional: X is (8, 3), w is (3,), and both X @ w and y are (8,). If an import produces y with shape (8, 1), multiplying it by (8,) logits broadcasts to (8, 8), pairing every label with every score. Averaging that matrix returns a scalar, but it isn't the rowwise log loss. The checks reject this mistake; convert a known single target column to y[:, 0] before fitting. Nonfinite features and labels outside {0, 1} also violate this trainer's Bernoulli model.

The negative intercept starts low when both signals are absent. Ambiguity's positive coefficient raises review risk, while the negative policy-support coefficient lowers it, holding the other feature fixed. Both feature weights rose on the first step because both features were larger among positive rows on average. Later steps separate their conditional effects; an initial gradient sign isn't the final coefficient sign.

Each progress line reports loss and weights after the same update, so snapshots stay comparable. The loss falls while the weights settle near the reference vector.

At t=0.50t=0.50, R3 needs review but scores 0.274, while R7 doesn't but scores 0.687. A straight boundary can't create an isolated pocket. In real data, also inspect labels and missing features before interpreting errors as proof that the relationship needs a nonlinear model.

A fitted logistic model places eight access requests on an ambiguity and policy-support map. Parallel probability contours at 0.20, 0.50, and 0.80 show how the action boundary moves with the threshold; R3 is missed at 0.50 and R7 is sent to review.
Changing the threshold moves the straight boundary without changing the fitted weights. At `t = 0.50`, `R3` is a missed review (`p = 0.274`) and `R7` is an unnecessary review (`p = 0.687`).

The fitted weights explain the training table. They don't pick a ; that job moves to requests the optimizer never saw.

Select a threshold on held-out requests

The fitted model emits a score in (0, 1). A routing policy needs a threshold t: send the request to review when p >= t. Selecting t on the training rows would reward .

Use a with cases the never saw. Here a false negative means automatic processing when review was required and costs $120. A false positive means unnecessary human review and costs $18.

The code below tests three cuts on the same eight held-out rows. It counts caught reviews (tp), unnecessary reviews (fp), and missed reviews (fn). Precision measures the fraction of the review queue that truly needed review; recall measures the fraction of required reviews caught. Before running it, expect the lowest cut to catch more required reviews and create more queue work.

choose-threshold-on-validation.py
import numpy as np

def sigmoid(z: np.ndarray) -> np.ndarray:
    return 1.0 / (1.0 + np.exp(-z))

def metrics(y: np.ndarray, p: np.ndarray, threshold: float) -> tuple[float, float, float, int]:
    pred = (p >= threshold).astype(int)
    tp = int(((pred == 1) & (y == 1)).sum())
    fp = int(((pred == 1) & (y == 0)).sum())
    fn = int(((pred == 0) & (y == 1)).sum())
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
    cost = 120 * fn + 18 * fp
    return precision, recall, f1, cost

w = np.array([-4.894, 3.399, -0.959])
X_val_raw = np.array([
    [1.2, 2.0], [2.1, 4.0], [2.4, 3.0], [3.0, 5.0],
    [3.6, 4.0], [3.2, 7.0], [4.2, 5.0], [2.8, 6.0],
])
y_val = np.array([0, 0, 1, 0, 1, 0, 1, 1])
X_val = np.c_[np.ones(len(X_val_raw)), X_val_raw]
p_val = sigmoid(X_val @ w)

for t in (0.20, 0.50, 0.80):
    precision, recall, f1, cost = metrics(y_val, p_val, t)
    print(f"t={t:.2f} precision={precision:.3f} recall={recall:.3f} f1={f1:.3f} cost=${cost}")

print("p_val:", np.round(p_val, 3))
print("cheapest tested cut:", min((0.20, 0.50, 0.80), key=lambda t: metrics(y_val, p_val, t)[3]))
Output
t=0.20 precision=0.667 recall=1.000 f1=0.800 cost=$36
t=0.50 precision=0.750 recall=0.750 f1=0.750 cost=$138
t=0.80 precision=1.000 recall=0.500 f1=0.667 cost=$240
p_val: [0.061 0.169 0.595 0.624 0.971 0.325 0.99  0.244]
cheapest tested cut: 0.2

Among these three cuts, t = 0.20 costs least. Compared with 0.50, it prevents one missed review and adds one unnecessary review: a net saving of $120 - $18 = $102. Once you select the threshold, freeze it and evaluate on an untouched test set. Validation cost was used to choose the policy, so it isn't an independent estimate of the selected policy's performance.[6]

There is also a probability-based decision rule, if the scores are calibrated. At p = 0.20, automatic processing has expected error cost 0.20 × $120 = $24; review has expected error cost 0.80 × $18 = $14.40. In general, review is cheaper when 18(1−p)≤120p18(1-p) \leq 120p, or p≥18/138≈0.130p \geq 18/138 \approx 0.130. That theoretical cut follows from our zero-cost-correct-decisions assumption. It needn't minimize observed cost on eight noisy cases, and it isn't justified by uncalibrated scores. For a live policy, estimate the full cost table and check reviewer capacity.

Precision, recall, F1, and class imbalance

For any threshold, fill the 2x2 . At t = 0.20, ask one concrete question for each validation request: did the route match the completed case?

Eight validation request scores occupy two lanes for their completed outcome. The probability axis marks a 0.20 review threshold. Two safe requests stay below the cut, two safe requests cross it, and all four requests needing review cross it.
Position is the score; the vertical cut is the policy. At t = 0.20, every required review is to the right of the cut. The two extra reviews are V6 and V4, which is why the validation cost is 2 × $18 = $36 rather than a $120 miss.

At this cut the four cells represent concrete operational outcomes:

  • True Positive (TP = 4): required reviews (V8, V3, V5, V7) routed to review. Correct labels don't prove that authorization or human review succeeds.
  • False Positive (FP = 2): requests not requiring review (V6, V4) enter the queue, costing $18 each under our assumptions.
  • True Negative (TN = 2): requests not requiring review (V1, V2) take the automatic route, with zero incremental cost in this toy table.
  • False Negative (FN = 0): a request requiring review would take the automatic route, costing $120 in this toy model. This label alone doesn't establish that a breach occurred.
pred autopred review
actual 0TN 2 (V1, V2)FP 2 (V6, V4)
actual 1FN 0TP 4 (V8, V3, V5, V7)

Six requests enter the queue, four of which need review: precision is 4/6 = 2/3. All four required reviews are caught: recall is 4/4 = 1. Their F1 is 2 × (2/3) × 1 / (2/3 + 1) = 0.8.

Read the matrix in the direction of the action:

  • = TP / (TP + FP): among requests routed to review, what fraction required it? Precision measures queue purity and alert trustworthiness.
  • = TP / (TP + FN): among requests requiring review, what fraction reached it? Recall measures safety coverage and catch rate.
  • = 2 * precision * recall / (precision + recall): the harmonic mean of precision and recall.

Precision is undefined when the selected queue is empty; recall is undefined when there are no positive labels. Our threshold helper uses zero for those summaries, a convention rather than an observed rate. Keep the class and queue counts alongside metrics. The reciprocal harmonic-mean expression below assumes positive precision and recall; use the count-based form or a declared zero-division convention at the boundaries.

Why F1 uses the harmonic mean

Why use the harmonic mean rather than the arithmetic average (P+R)/2(P + R) / 2?

Consider a degenerate detector that flags only a single, glaringly obvious request where ambiguity is extreme (x1=10,x2=0x_1 = 10, x_2 = 0). If that one request is positive, precision is 1/1=1.01 / 1 = 1.0. But across 100 actual risky requests, recall is only 1/100=0.011 / 100 = 0.01.

The arithmetic mean would be (1.0+0.01)/2=0.505(1.0 + 0.01) / 2 = 0.505, making an utterly useless detector look moderately successful. The harmonic mean inverts this:

F1=21P+1R=2PRP+R=2⋅1.0⋅0.011.0+0.01≈0.0198F_1 = \frac{2}{\frac{1}{P} + \frac{1}{R}} = \frac{2 P R}{P + R} = \frac{2 \cdot 1.0 \cdot 0.01}{1.0 + 0.01} \approx 0.0198

Because the harmonic mean is pulled strongly toward the smaller value, it plummets whenever either precision or recall fails. A high F1 requires both rates to remain high.

The accuracy trap under class imbalance

The four cells also explain why accuracy alone is dangerous when positive cases are rare. Try the extreme policy below: send every request to automatic processing.

accuracy-fails-on-rare-review-cases.py
import numpy as np

y_true = np.array([1] * 4 + [0] * 96)
predict_auto_process_everything = np.zeros(100, dtype=int)
accuracy = (predict_auto_process_everything == y_true).mean()
recall = ((predict_auto_process_everything == 1) & (y_true == 1)).sum() / (y_true == 1).sum()
print("accuracy:", round(float(accuracy), 3))
print("review recall:", round(float(recall), 3))
Output
accuracy: 0.96
review recall: 0.0

The policy gets 96 of 100 rows right by exploiting the negative majority, yet it catches none of the four requests that required review. Accuracy answers a different question from review recall. When positive events are rare, a trivial majority-class baseline scores high accuracy while failing completely at its job.

ROC and precision-recall curves measure ranking

To draw an ROC (Receiver Operating Characteristic) curve, vary the threshold from high to low and plot recall (true-positive rate) on the y-axis against false-positive rate, FP / (FP + TN), on the x-axis. At t = 0.20, those coordinates are (2/4, 4/4) = (0.5, 1.0). Both classes must be present to estimate both rates.

answers a ranking question: how often does a random positive score rank above a random negative score? Ties get half credit. AUC can tell you whether ordering is useful, but it never chooses a business threshold for you.

Before running the pairwise check, count the combinations: four positive and four negative validation rows should produce 16 comparisons.

auc-from-positive-negative-pairs.py
import numpy as np

p_val = np.array([0.061, 0.169, 0.595, 0.624, 0.971, 0.325, 0.990, 0.244])
y_val = np.array([0, 0, 1, 0, 1, 0, 1, 1])
positive = p_val[y_val == 1]
negative = p_val[y_val == 0]
pair_scores = [(pos > neg) + 0.5 * (pos == neg) for pos in positive for neg in negative]
auc = float(np.mean(pair_scores))
print("positive-negative pairs:", len(pair_scores))
print("validation AUC:", round(auc, 3))
Output
positive-negative pairs: 16
validation AUC: 0.812

This slice gives 13 ranking wins out of 16. The ordering is useful, even though one fixed threshold can still make an expensive mistake.

The ROC staircase marks validation thresholds 0.80, 0.50, and 0.20. A four-by-four grid compares each positive score with each negative score: 13 of 16 pairwise comparisons rank the positive higher, for AUC 0.8125.
The staircase sweeps every validation score. The matrix gives the equivalent ranking view: positive requests outrank negatives in `13` of `16` pairs, so AUC is `13 / 16 = 0.8125`.

ROC-AUC versus PR-AUC under class imbalance

ROC curves measure threshold-independent discriminative ranking. The two axes normalize exclusively within each ground-truth class:

TPR=TPTP+FN=TPP,FPR=FPFP+TN=FPN\text{TPR} = \frac{\text{TP}}{\text{TP} + \text{FN}} = \frac{\text{TP}}{P}, \qquad \text{FPR} = \frac{\text{FP}}{\text{FP} + \text{TN}} = \frac{\text{FP}}{N}

Population ROC rates depend on the class-conditional score distributions, not directly on the proportion of positives. Changing prevalence while preserving those distributions leaves the population ROC unchanged. Arbitrarily adding benign requests doesn't guarantee this: their score distribution may differ, and finite-sample estimates also vary. Uniformly replicating every negative row preserves an empirical ROC exactly; adding only easy low-score negatives doesn't.

Test that distinction on our eight scores. No model is retrained. Compare an unchanged negative-score mix with 100 extra negatives whose scores are all zero:

prevalence-invariance-needs-the-same-score-mix.py
import numpy as np

p = np.array([0.061, 0.169, 0.595, 0.624, 0.971, 0.325, 0.990, 0.244])
y = np.array([0, 0, 1, 0, 1, 0, 1, 1])
positive = p[y == 1]
negative = p[y == 0]

def audit(labels: np.ndarray, scores: np.ndarray) -> tuple[float, float, float]:
    pos = scores[labels == 1]
    neg = scores[labels == 0]
    pairs = (pos[:, None] > neg[None, :]) + 0.5 * (pos[:, None] == neg[None, :])
    selected = scores >= 0.20
    precision = float(np.mean(labels[selected]))
    return float(np.mean(neg >= 0.20)), float(np.mean(pairs)), precision

same_mix_y = np.r_[np.ones(len(positive), dtype=int), np.zeros(25 * len(negative), dtype=int)]
same_mix_p = np.r_[positive, np.repeat(negative, 25)]
easy_y = np.r_[y, np.zeros(100, dtype=int)]
easy_p = np.r_[p, np.zeros(100)]

for name, labels, scores in (
    ("original", y, p),
    ("same-negative-mix", same_mix_y, same_mix_p),
    ("extra-easy-negatives", easy_y, easy_p),
):
    fpr, auc, precision = audit(labels, scores)
    print(name, "fpr", round(fpr, 4), "auc", round(auc, 4), "precision", round(precision, 4))
Output
original fpr 0.5 auc 0.8125 precision 0.6667
same-negative-mix fpr 0.5 auc 0.8125 precision 0.0741
extra-easy-negatives fpr 0.0192 auc 0.9928 precision 0.6667

The unchanged mix lowers precision by adding false positives in the same proportion. The easy-negative addition instead inflates AUC while leaving the selected queue unchanged. State how an evaluation set was sampled before interpreting its ranking metrics.

That invariance becomes a blind spot when positives are rare. A classifier with recall 0.9 and false-positive rate 0.1 selects 90 true positives and 10 false positives from 100 positives and 100 negatives: precision 90 / 100 = 0.90. With only 100 positives among 10,000 requests (a 1:99 ratio), the exact same rates produce 90 true positives and 990 false positives: precision collapses to 90 / (90 + 990) ≈ 0.083.

Under the assumed unchanged rates, the ROC point stays (0.1, 0.9), but 91.7% of the queue consists of false positives. A exposes that burden.[1] For rare events, report AP together with counts, recall, cost, and reviewer capacity at the chosen threshold. Neither ROC-AUC nor an area under a PR curve alone measures operating effort.

The precision-recall curve marks thresholds 0.80, 0.50, and 0.20 beside the same eight validation requests ordered by score. As the queue widens, V4 enters just before V3 and lowers precision; the three queue rows show the true and false positives at each cut.
Precision isn't monotonic as you lower the threshold. `V4` (score `0.624`, actual 0) enters before `V3` (score `0.595`, actual 1), so precision drops to `0.667` and then recovers to `0.75` at `t = 0.50`. The `(0, 1)` endpoint is a plotting convention for an empty queue; no cases contribute measured precision there.

summarizes the precision-recall curve with a stepwise weighted average of precision at each positive-label recall increase. People sometimes label AP as PR-AUC, but trapezoidal area under the same curve can produce a different number, so name the statistic you compute.[7]

Here the four positive rows enter at ranks 1, 2, 4, 6, with precisions 1, 1, 3/4, 4/6. Each adds one quarter of the total recall, so AP is their mean, about 0.854. This shortcut assumes distinct scores. Tied scores must enter together at one threshold; use average_precision_score for that general case.[7]

average-precision-on-validation.py
import numpy as np

p_val = np.array([0.061, 0.169, 0.595, 0.624, 0.971, 0.325, 0.990, 0.244])
y_val = np.array([0, 0, 1, 0, 1, 0, 1, 1])

# For distinct scores, AP is the mean precision at each positive row's rank.
order = np.argsort(-p_val)
y_sorted = y_val[order]
tp_cum = np.cumsum(y_sorted)
fp_cum = np.cumsum(1 - y_sorted)
precision_at = tp_cum / (tp_cum + fp_cum)
ap = float(precision_at[y_sorted == 1].mean())
print("validation average precision:", round(ap, 3))
print("validation ROC-AUC:   ", 0.812)
Output
validation average precision: 0.854
validation ROC-AUC:    0.812

On this balanced slice AP and ROC-AUC are close. When positives become rare, report both. ROC-AUC can stay high while AP falls with positive prevalence and with the quality of ranking near the top of the queue.

Calibration: do scores represent frequencies?

A model can rank access-change requests well while producing over-confident or under-confident numbers. If requests scored near 0.80 require review only half the time, showing 80% to an operator misstates risk.

Logistic regression is often described as "already a probability model" because it optimizes log loss. That interpretation depends on the linear log-odds assumption and the data.

Shrinkage (scikit-learn's default C = 1.0, an L2 penalty) pulls coefficients toward zero, which can narrow the score range and move predictions toward the base rate. Even this unregularized eight-row fit isn't a frequency guarantee. Audit calibration on held-out labels.[1]

A reliability diagram compares average score with observed positive frequency per bin. Our lowest bin has three requests with mean score 0.158, and one actually needs review (1/3 ≈ 0.333). Its absolute gap is about 0.175; because it contains 3/8 of the data, it contributes about 0.066 to .

We use binary positive-class ECE: bin the review probabilities, not the confidence in whichever class won. With BbB_b denoting the rows in bin bb, sum these weighted gaps:

ECE=∑b∣Bb∣n∣avg⁡i∈Bb(pi)−avg⁡i∈Bb(yi)∣\mathrm{ECE} = \sum_b \frac{|B_b|}{n}\left|\operatorname{avg}_{i\in B_b}(p_i) - \operatorname{avg}_{i\in B_b}(y_i)\right|

ECE depends on sample size, prevalence, and bin choices. It has no universal pass threshold. Pair it with a , the mean squared difference between predicted probabilities and binary outcomes, when you need a bin-independent probability-quality summary. Brier score measures more than calibration: discrimination and outcome uncertainty matter too, so a lower Brier score doesn't by itself prove better calibration.[8]

State the binning scheme and measure on held-out labels. Fit a post-hoc map on data separate from the classifier's fitting data, then verify the map on fresh labels. Platt scaling fits a one-dimensional logistic curve; isotonic regression fits a nondecreasing step function; scaling divides logits by a learned positive scalar.[9] A strictly increasing map preserves binary ranking, while isotonic can introduce ties and change AUC.[8] Reassess the numerical operating threshold on suitable validation data after calibration; don't tune it on the final test labels. Positive temperature scaling alone leaves the binary 0.5 decision boundary unchanged.

The next output makes the ECE bookkeeping visible rather than hiding it behind one number.

calibration-on-validation-labels.py
import numpy as np

p_val = np.array([0.061, 0.169, 0.595, 0.624, 0.971, 0.325, 0.990, 0.244])
y_val = np.array([0, 0, 1, 0, 1, 0, 1, 1])
edges = np.linspace(0.0, 1.0, 5)
ece = 0.0
for lo, hi in zip(edges[:-1], edges[1:]):
    mask = (p_val >= lo) & ((p_val < hi) if hi < 1.0 else (p_val <= hi))
    if mask.any():
        avg_p = p_val[mask].mean()
        frac_pos = y_val[mask].mean()
        ece += mask.mean() * abs(avg_p - frac_pos)
        closing = "]" if hi == 1.0 else ")"
        print(f"[{lo:.2f}, {hi:.2f}{closing} n={mask.sum()} avg_p={avg_p:.3f} frac_pos={frac_pos:.3f}")
print("validation ECE:", round(float(ece), 3))
print("validation Brier:", round(float(np.mean((p_val - y_val)**2)), 3))
Output
[0.00, 0.25) n=3 avg_p=0.158 frac_pos=0.333
[0.25, 0.50) n=1 avg_p=0.325 frac_pos=0.000
[0.50, 0.75) n=2 avg_p=0.609 frac_pos=0.500
[0.75, 1.00] n=2 avg_p=0.980 frac_pos=1.000
validation ECE: 0.139
validation Brier: 0.158

In this sample, the first bin's observed review frequency exceeds its mean prediction (0.333 versus 0.158); the second is lower (0 versus 0.325). Those tiny bin counts don't establish population miscalibration. Calling the first bin "under-confident" would also obscure its high predicted confidence in the negative class. ECE uses an absolute gap regardless of direction.

Eight validation requests are enough for arithmetic, not a release gate. To see why, flip one disputed label and measure both metrics:

one-label-changes-small-sample-conclusions.py
import numpy as np

def f1_at_half(y: np.ndarray, p: np.ndarray) -> float:
    pred = p >= 0.5
    tp = ((pred == 1) & (y == 1)).sum()
    fp = ((pred == 1) & (y == 0)).sum()
    fn = ((pred == 0) & (y == 1)).sum()
    return float(2 * tp / (2 * tp + fp + fn))

def ece(y: np.ndarray, p: np.ndarray) -> float:
    edges = np.linspace(0.0, 1.0, 5)
    total = 0.0
    for lo, hi in zip(edges[:-1], edges[1:]):
        mask = (p >= lo) & ((p < hi) if hi < 1.0 else (p <= hi))
        if mask.any():
            total += mask.mean() * abs(p[mask].mean() - y[mask].mean())
    return float(total)

p = np.array([0.061, 0.169, 0.595, 0.624, 0.971, 0.325, 0.990, 0.244])
base = np.array([0, 0, 1, 0, 1, 0, 1, 1])
one_flip = base.copy()
one_flip[3] = 1
for name, labels in (("base", base), ("one flip", one_flip)):
    print(f"{name:8s} f1={f1_at_half(labels, p):.3f} ece={ece(labels, p):.3f}")
Output
base     f1=0.750 ece=0.139
one flip f1=0.889 ece=0.209

One label moves both results sharply. Treat this slice as a teaching audit, not a release gate. Two questions remain: what does each threshold cost, and do the scores describe frequencies? First, compare routing cost across thresholds:

Three threshold policies show validation error cost split into unnecessary-review cost and missed-review cost. At thresholds 0.20, 0.50, and 0.80, totals are 36, 138, and 240 dollars; the lowest tested total comes from two false positives and no false negatives.
Threshold 0.20 wins this validation audit because two $18 reviews cost less than one $120 missed review. It's a candidate policy, not a permanent constant.

Cost chooses an action. It doesn't say whether 0.87 means 87%. The reliability diagram asks that second question:

Four calibration-bin points compare mean predicted review score with observed review frequency against the perfect-calibration diagonal. Their bin sizes weight absolute gaps of 0.175, 0.325, 0.110, and 0.020 into ECE contributions 0.066, 0.041, 0.027, and 0.005, totaling 0.139.
Each bin contributes its score-frequency gap weighted by its share of the eight validation requests: `0.066 + 0.041 + 0.027 + 0.005 ≈ 0.139`. Values are rounded; the evidence is still too small for a release claim.

Verify the NumPy fit with scikit-learn

A from-scratch implementation gains a useful check by matching a maintained implementation under equivalent settings.[3] The September 21, 2026 library check uses NumPy 2.5.3 and scikit-learn 1.9.1. LogisticRegression defaults to L2 (C=1.0, l1_ratio=0). Since 1.8, C=np.inf selects an unpenalized fit and penalty is deprecated; use C and l1_ratio with a compatible solver.[10]

Set C=np.inf here so the library solve is comparable to our unregularized gradient-descent fit. The solver is L-BFGS rather than full-batch gradient descent, so the weights should be close, not bit-identical.

compare-with-scikit-learn.py
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import f1_score, roc_auc_score

X_raw = np.array([
    [1.0, 1.0], [1.5, 2.0], [2.0, 3.0], [3.5, 6.0],
    [4.0, 7.0], [4.5, 5.0], [2.8, 4.0], [3.2, 8.0],
])
y = np.array([0, 0, 1, 1, 1, 1, 0, 0])
X = np.c_[np.ones((len(X_raw), 1)), X_raw]
w_ours = np.array([-4.894, 3.399, -0.959])
X_val_raw = np.array([
    [1.2, 2.0], [2.1, 4.0], [2.4, 3.0], [3.0, 5.0],
    [3.6, 4.0], [3.2, 7.0], [4.2, 5.0], [2.8, 6.0],
])
y_val = np.array([0, 0, 1, 0, 1, 0, 1, 1])
X_val = np.c_[np.ones(len(X_val_raw)), X_val_raw]

clf = LogisticRegression(C=np.inf, solver="lbfgs", max_iter=5000, fit_intercept=False)
clf.fit(X, y)
w_sk = clf.coef_[0]
print("sklearn weights:", np.round(w_sk, 3))
print("our weights   :", w_ours)
print("weights close :", np.allclose(w_sk, w_ours, atol=0.05))
p_val = clf.predict_proba(X_val)[:, 1]
print("validation F1 @ 0.20:", round(f1_score(y_val, (p_val >= 0.20).astype(int)), 3))
print("validation AUC:", round(roc_auc_score(y_val, p_val), 3))
Output
sklearn weights: [-4.914  3.414 -0.964]
our weights   : [-4.894  3.399 -0.959]
weights close : True
validation F1 @ 0.20: 0.8
validation AUC: 0.812

Close weights and matching metrics support our implementation on this fixture; they aren't a proof for other inputs. Compare objectives, logits, rank, and stopping tolerances when a match fails. The validation metrics describe eight constructed rows, not release performance.

Multiclass routing: the softmax extension

Binary logistic regression decides between two actions. Live policy engines often require more granular dispatch. Suppose our access-control engine sorts requests into three tiers:

  • Class 0: Auto-Approve (standard permissions under established policy)
  • Class 1: Standard Review (moderate risk ticket queue)
  • Class 2: Security Escalation (severe risk requiring immediate review and credential freeze)

These are classifier labels. Permission checks and any credential actions are separately authorized workflow steps.

Linear heads and the softmax function

Instead of a single scalar logit, we assign each of the KK classes its own weight vector wk∈Rdw_k \in \mathbb{R}^d (including intercept):

zk=wkTx=∑j=1dwk,jxjfor k∈{0,1,…,K−1}z_k = w_k^T x = \sum_{j=1}^d w_{k,j} x_j \quad \text{for } k \in \{0, 1, \dots, K-1\}

In matrix form, the logit vector for one request is z=WTxz = W^T x, where W∈Rd×KW \in \mathbb{R}^{d \times K}.

To convert unconstrained real logits z∈RKz \in \mathbb{R}^K into a valid probability distribution where each pk>0p_k > 0 and ∑k=0K−1pk=1\sum_{k=0}^{K-1} p_k = 1, we apply the function:[4][11]

pk=P(Y=k∣x)=ezk∑j=0K−1ezjp_k = P(Y = k \mid x) = \frac{e^{z_k}}{\sum_{j=0}^{K-1} e^{z_j}}

Notice that when K=2K = 2, softmax simplifies directly back to the binary sigmoid. Setting class logits to z0z_0 and z1z_1:

p1=ez1ez0+ez1=1ez0−z1+1=11+e−(z1−z0)=σ(z1−z0)p_1 = \frac{e^{z_1}}{e^{z_0} + e^{z_1}} = \frac{1}{e^{z_0 - z_1} + 1} = \frac{1}{1 + e^{-(z_1 - z_0)}} = \sigma(z_1 - z_0)

Binary logistic regression is two-class softmax regression with the reference class logit z0z_0 pinned to zero.

Categorical cross-entropy and its gradient

For a one-hot ground-truth vector y∈{0,1}Ky \in \{0, 1\}^K (yk=1y_k = 1 for the true class, 00 otherwise), the likelihood of a single observation under the categorical distribution is ∏k=0K−1pkyk\prod_{k=0}^{K-1} p_k^{y_k}. Taking the negative log-likelihood across NN independent requests yields the Categorical Cross-Entropy loss:

L(W)=−1N∑i=1N∑k=0K−1yi,klog⁡pi,kL(W) = -\frac{1}{N} \sum_{i=1}^N \sum_{k=0}^{K-1} y_{i,k} \log p_{i,k}

Differentiating categorical cross-entropy with respect to class weight vector wkw_k gives:

∇wkL=1N∑i=1N(pi,k−yi,k)xi\nabla_{w_k} L = \frac{1}{N} \sum_{i=1}^N (p_{i,k} - y_{i,k}) x_i

Stacking all KK weight vectors into weight matrix W∈Rd×KW \in \mathbb{R}^{d \times K} produces the batch gradient:

∇WL=1NXT(Y^−Y)\nabla_W L = \frac{1}{N} X^T (\hat{Y} - Y)

where X∈RN×dX \in \mathbb{R}^{N \times d}, Y^∈RN×K\hat{Y} \in \mathbb{R}^{N \times K} contains the softmax probabilities, and Y∈RN×KY \in \mathbb{R}^{N \times K} is the one-hot target matrix. The exact same error-residual form, features dotted with predicted minus actual, extends directly from scalar linear regression to multiclass classification.

Adding the same coefficient vector to every class head adds an identical feature-dependent value to every logit and leaves softmax unchanged. Thus unrestricted class weights aren't identifiable from probabilities alone. A reference class or an appropriate constraint removes that redundancy; convexity doesn't imply a unique unrestricted weight matrix.

Numerical stability: the log-sum-exp trick

Direct exponentials overflow around logits 709.8 in float64 or 88.7 in float32. Subtracting c=max⁡jzjc=\max_jz_j preserves softmax mathematically and stabilizes those exponentials:

ezk∑jezj=ezk−c⋅ec∑jezj−c⋅ec=ezk−c∑jezj−c\frac{e^{z_k}}{\sum_j e^{z_j}} = \frac{e^{z_k - c} \cdot e^c}{\sum_j e^{z_j - c} \cdot e^c} = \frac{e^{z_k - c}}{\sum_j e^{z_j - c}}

With representable finite differences, zk−c≤0z_k-c\le0, so the largest exponential is 1. Tiny probabilities can still underflow or round to zero; invalid inputs and extreme subtraction overflow need separate handling. Compute cross-entropy from shifted logits, not clipped probabilities. Clipping would cap the penalty for an extremely wrong prediction.

Fit a three-class routing classifier on six historical requests using NumPy:

multiclass-softmax-routing.py
import numpy as np

def softmax(Z: np.ndarray) -> np.ndarray:
    shift_Z = Z - np.max(Z, axis=1, keepdims=True)
    exp_Z = np.exp(shift_Z)
    return exp_Z / np.sum(exp_Z, axis=1, keepdims=True)

def categorical_cross_entropy_from_logits(Y_onehot: np.ndarray, Z: np.ndarray) -> float:
    shifted = Z - np.max(Z, axis=1, keepdims=True)
    log_normalizer = np.log(np.sum(np.exp(shifted), axis=1, keepdims=True))
    log_probabilities = shifted - log_normalizer
    return float(-np.mean(np.sum(Y_onehot * log_probabilities, axis=1)))

# 6 requests: [ambiguity, auto-policy support] + intercept column
# Target routing classes: 0: Auto-Approve, 1: Standard Review, 2: Escalation
X_raw = np.array([
    [1.0, 8.0],
    [1.5, 7.0],
    [2.5, 4.0],
    [3.2, 5.0],
    [4.8, 1.5],
    [5.0, 1.0],
])
X = np.c_[np.ones(len(X_raw)), X_raw]
y_classes = np.array([0, 0, 1, 1, 2, 2])
K = 3
N, d = X.shape
Y_onehot = np.eye(K)[y_classes]

W = np.zeros((d, K))
lr = 0.2
for epoch in range(1000):
    P = softmax(X @ W)
    grad = (1 / N) * (X.T @ (P - Y_onehot))
    W -= lr * grad

P_final = softmax(X @ W)
preds = np.argmax(P_final, axis=1)
loss = categorical_cross_entropy_from_logits(Y_onehot, X @ W)
print("final loss:", round(loss, 4))
print("predicted classes:", preds.tolist())
print("all correct:", bool((preds == y_classes).all()))
print("sample probabilities row 0 (auto):", P_final[0].round(3).tolist())
print("sample probabilities row 4 (escalate):", P_final[4].round(3).tolist())

extreme_logits = np.array([[1000.0, 0.0, -1000.0]])
extreme_target = np.array([[0.0, 0.0, 1.0]])
extreme_loss = categorical_cross_entropy_from_logits(extreme_target, extreme_logits)
assert np.isfinite(extreme_loss) and np.isclose(extreme_loss, 2000.0)
print("extreme wrong log loss:", round(extreme_loss, 1))
Output
final loss: 0.0055
predicted classes: [0, 0, 1, 1, 2, 2]
all correct: True
sample probabilities row 0 (auto): [1.0, 0.0, 0.0]
sample probabilities row 4 (escalate): [0.0, 0.005, 0.995]
extreme wrong log loss: 2000.0

"All correct" scores the six training rows. The printed [1.0, 0.0, 0.0] is rounded, not calibrated certainty. The extreme wrong prediction costs 2,000 nats; clipping its target probability at 1e-15 would misleadingly report only about 34.54. This separable toy fit also doesn't establish finite maximum-likelihood coefficients.

The logistic and softmax models give linear log-odds scores followed by normalized exponential curves. Before moving to trees, compare that objective with two other uses of feature geometry: a separating margin and a covariance kernel.

Margins, kernels, and predictive uncertainty

A can classify the same access requests with a linear score f(x)=w⊤x+bf(x)=w^\top x+b. Its question differs from logistic regression's: can the classes be separated with a wide geometric buffer around the boundary?

In the usual soft-margin SVM, support vectors lie on or inside the margin, or on the wrong side of the decision boundary. Rows comfortably beyond the margin don't contribute hinge-loss pressure.[12]

Encode needs review as y=+1y=+1 and review not required as y=−1y=-1. The functional margin is yf(x)yf(x): positive means the request is classified correctly. Dividing by the feature-weight norm gives the signed geometric margin for a linear model.

A standard charges:

ℓhinge(y,f(x))=max⁡(0,1−yf(x))\ell_{\mathrm{hinge}}(y,f(x))=\max(0,1-yf(x))

A positive request with score 0.4 is classified correctly, but its margin is only 0.4, so hinge loss is 0.6. A positive score of 1.4 clears the unit margin and incurs zero hinge loss.

The logistic loss on the second request is about 0.220; hinge loss is zero after the unit functional margin. A usual soft-margin objective balances violations with weight regularization, for example 12∥w∥2+C∑iℓhinge(yi,f(xi))\frac12\|w\|^2+C\sum_i\ell_{\mathrm{hinge}}(y_i,f(x_i)). LinearSVC defaults to squared hinge and also regularizes the intercept through liblinear. Name the loss, penalty, and normalization when comparing implementations.[12]

A linear SVM still can't carve out an isolated exception that requires a curved boundary. A replaces an explicit feature expansion with a pairwise similarity function. The kernel is one common choice:

k(x,x′)=exp⁡(−∥x−x′∥22ℓ2)k(x,x')=\exp\left(-\frac{\lVert x-x'\rVert^2}{2\ell^2}\right)

Here ℓ\ell is the length scale. Identical points have similarity 1. With ℓ=1\ell=1, points one unit apart have similarity 0.607, and points three units apart have similarity 0.011.

An RBF SVM can draw nonlinear boundaries around feature neighborhoods without materializing an infinite-dimensional feature map. Its decision score still isn't a calibrated class probability. Apply a separate held-out calibration procedure before displaying percentages, and scale features before treating Euclidean distance as meaningful.[12]

A can use that same RBF kernel for a different job: define covariance between unknown function values and return a predictive mean together with uncertainty. Switch briefly to a regression example, not the binary review label: observe a continuous value y=2y=2 at input 0. Assume zero prior mean, unit prior variance, length scale 1, and Gaussian observation-noise variance 0.04. At the observed input, the posterior mean is 2/1.04 ≈ 1.923 and latent variance is 1 - 1/1.04 ≈ 0.0385. At a new input the same conditioning calculation gives:[13]

μ∗(x)=2k(x,0)1.04,σ∗2(x)=1−k(x,0)21.04\mu_*(x)=\frac{2k(x,0)}{1.04},\qquad \sigma_*^2(x)=1-\frac{k(x,0)^2}{1.04}

Near the observation, the mean stays close to 2 and uncertainty shrinks. Far away, similarity vanishes, the mean returns toward its zero prior, and standard deviation rises toward 1. This variance describes the latent function; predicting a new noisy observation adds 0.04 to it. GP classification requires a non-Gaussian likelihood and doesn't use these regression equations unchanged.

This is predictive uncertainty under the stated kernel, noise, and prior assumptions. Check calibration on shifted traffic before relying on the intervals. Exact dense GP regression also requires roughly O(n3)O(n^3) fitting time and O(n2)O(n^2) storage for nn observations, so it fits smaller data problems better than large unapproximated serving datasets.[13]

The following dependency-free comparison makes both distinctions concrete: it prints the two loss objectives and the one-observation GP posterior:

compare-margin-loss-and-kernel-uncertainty.py
from math import exp, log1p, sqrt

examples = [
    ("required-review", 1, 0.4),
    ("confident-review", 1, 1.4),
    ("missed-review", 1, -0.2),
    ("safe-auto", -1, -1.2),
]

for name, label, score in examples:
    margin = label * score
    hinge = max(0.0, 1.0 - margin)
    logistic = log1p(exp(-margin))
    print(f"{name:16} margin={margin:4.1f} hinge={hinge:.3f} logistic={logistic:.3f}")

print("distance  kernel  gp_mean  gp_std")
for distance in [0.0, 1.0, 3.0]:
    similarity = exp(-(distance**2) / 2.0)
    mean = 2.0 * similarity / 1.04
    standard_deviation = sqrt(1.0 - similarity**2 / 1.04)
    print(f"{distance:8.1f}  {similarity:.3f}   {mean:.3f}    {standard_deviation:.3f}")
Output
required-review  margin= 0.4 hinge=0.600 logistic=0.513
confident-review margin= 1.4 hinge=0.000 logistic=0.220
missed-review    margin=-0.2 hinge=1.200 logistic=0.798
safe-auto        margin= 1.2 hinge=0.000 logistic=0.263
distance  kernel  gp_mean  gp_std
     0.0  1.000   1.923    0.196
     1.0  0.607   1.166    0.804
     3.0  0.011   0.021    1.000

The margin rows compare two loss terms; fitted models also depend on their penalties. In our one-observation stationary-RBF example, uncertainty rises with distance from that observation. With other kernels, priors, and several observations, uncertainty needn't be a simple monotone function of distance.

MethodWhat it optimizes or predictsWhat still needs checking
Logistic regressionLog loss and a sigmoid-based class scoreCalibration, class balance, and the decision threshold
Linear SVMA separating margin under hinge or squared-hinge lossFeature scaling, regularization, and separate probability calibration
RBF-kernel SVMA nonlinear margin based on distance to support vectorsKernel bandwidth, overfitting, and held-out operating cost
Gaussian processA posterior mean and variance under a chosen covariance kernelPrior assumptions, , shift, and cubic exact-training cost

If a run diverges from its expected numbers, diagnose the boundary between data, objective, and implementation before blaming the optimizer.

When the numbers look wrong

SymptomEvidence to inspectNext check
Scores cluster near the base rateSignal strength, inherent uncertainty, penalties, and convergenceCompare with a constant baseline on fresh labels before adding features or
Lower threshold catches more reviewsQueue counts, false-positive burden, cost table, and capacityChoose a policy on validation and score it on an untouched test set
Good AUC but large held-out bin gapsBin populations, label quality, sampling, and subgroup/traffic differencesFit a justified calibrator on separate data, then verify reliability
NumPy and library coefficients differEquivalent designs/objectives, intercept handling, scaling, rank, and stopping tolerancesCompare logits and objective values as well as weights; use C=np.inf for this unpenalized comparison
Loss grows after an updateFinite inputs, gradient calculation, step size, and the loss implementationTrace the first failing update; compare a smaller rate and stable logit-space loss

Try it yourself

Use the same training and validation rows. Follow the order: verify the update, stress the optimizer, choose a policy, then test how fragile the probability story is. Fit only on training; choose policy only on validation.

  1. Start with w = [0, 0, 0] and compute the gradient vector on the full batch by hand for the initial step (lr = 0.2). Verify it matches the first printed line of the NumPy run: w = [0, 0.069, 0.075].
  2. Change the learning rate to 2.0. Print the first ten losses instead of only the final loss. Does each update improve the objective? Explain any increases in terms of step size and feature scale.
  3. Sweep thresholds [0.2, 0.35, 0.5, 0.65, 0.8] on validation and print precision, recall, F1, and cost. Which threshold minimizes the stated cost?
  4. Bin validation probabilities into four equal-width bins, compute ECE by hand, and compare with code output. Repeat with eight bins and explain why the number changes.
  5. Flip one validation label and measure F1 and ECE again. Write the argument for gathering a larger, later-in-time validation set before publishing probability claims.

Check your calculations: the initial mean gradient is [0, -0.34375, -0.375]. The five thresholds give costs $36, $138, $138, $240, $240, respectively. Four-bin ECE is about 0.139; eight-bin ECE is about 0.154 using the displayed three-decimal scores. The changed bins expose gaps that previously canceled within a bin. Neither value settles calibration on such a small sample.

Turn two evidence scores into a probability, choose a threshold from the stated dollar costs, and keep a ranking result separate from a calibration gap. On the training table, R3 still scores only 0.274 at the fitted weights: moderate ambiguity, moderate policy support, but it needed review. A linear score has no way to carve out that exception without also moving nearby automatic requests. Axis-aligned splits on this same table are how you check whether a rule can isolate R3.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.A binary logistic model has intercept 1 and feature coefficients [3, −4]. For feature point [2, 1], review is selected when p ≥ 0.8. What are the logit, signed orthogonal distance to the p = 0.5 boundary in these feature units, and action?
2.A two-row mean-BCE update starts at zero weights with design rows [1, 2] and [1, −1], and labels [1, 0]. Use learning rate 0.4. What are the mean weight gradient and new weights?
3.A validation study compares two candidates. H has TP = 4, FP = 1 and FN = 2; L has TP = 6, FP = 3 and FN = 0. An unnecessary review costs 20,amissedrequiredreview20, a missed required review 80, and each selected request consumes one of seven review slots. Which candidate has the smaller feasible observed bill?
4.At one threshold, recall is 0.8 and false-positive rate is 0.02. A deployment population contains 100 positives and 19,900 negatives. Assume the class-conditional score distributions and these rates remain unchanged from validation. What are the selected precision and the ROC point (FPR, TPR)?
5.Four scored rows are (0.9, positive), (0.8, negative), (0.8, positive), and (0.3, negative). Give tied positive-negative pairs half credit for ROC-AUC, and collect each tied score group together for average precision. What are ROC-AUC and AP?
6.Predictions [0.1, 0.3, 0.6, 0.8] have labels [0, 1, 0, 1]. For binary positive-class ECE, use bins [0, 0.5) and [0.5, 1]. What are ECE and mean Brier score?
7.A three-class model emits logits [1001, 1000, 999], and class index 2 is the target. At temperature one, what are the stable categorical loss and the target-class logit derivative, to three decimals?
8.An SVM row has signed label −1 and decision score +0.3. A separate RBF comparison has distance 2 and length scale 2. What are the functional margin, ordinary hinge loss and kernel similarity, to three decimals?
9.In a separate one-observation GP regression, the prior has zero mean and unit latent variance. The observation is y = 3 with independent Gaussian noise variance 0.25. At a test point the kernel similarity to that observation is 0.5 and prior self-covariance is 1. What are posterior mean, latent variance, and variance of a new noisy observation?
10.A fresh worksheet supplies trustworthy review-required probabilities [0.08, 0.12, 0.35, 0.70]. Correct decisions cost zero; a missed required review costs 90andanunnecessaryreviewcosts90 and an unnecessary review costs 10. Compare cuts 0.1, 0.3 and 0.5 under capacity two, with review selected at p ≥ cut. Which tested policy has the smallest feasible expected bill?

10 questions remaining.

References

The Elements of Statistical Learning.

Hastie, T., Tibshirani, R., Friedman, J. · 2009

https://hastie.su.domains/ElemStatLearn/

Array programming with NumPy.

Harris, C. R., et al. · 2020 · Nature

https://www.nature.com/articles/s41586-020-2649-2

Scikit-learn: Machine Learning in Python.

Pedregosa, F., et al. · 2011 · JMLR

https://jmlr.org/papers/v12/pedregosa11a.html

Pattern Recognition and Machine Learning.

Bishop, C. M. · 2006

https://www.microsoft.com/en-us/research/publication/pattern-recognition-machine-learning/

Machine Learning: A Probabilistic Perspective.

Murphy, K. P. · 2012

https://probml.github.io/pml-book/book0.html

Tuning the decision threshold for class prediction

scikit-learn Developers · 2026

https://scikit-learn.org/stable/modules/classification_threshold.html

average_precision_score

scikit-learn Developers · 2026

https://scikit-learn.org/stable/modules/generated/sklearn.metrics.average_precision_score.html

Probability calibration

scikit-learn Developers · 2026

https://scikit-learn.org/stable/modules/calibration.html

On Calibration of Modern Neural Networks

Guo, C., Pleiss, G., Sun, Y., & Weinberger, K. Q. · 2017

https://arxiv.org/abs/1706.04599

LogisticRegression

scikit-learn Developers · 2026

https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.html

Deep Learning

Goodfellow, I., Bengio, Y., Courville, A. · 2016

https://www.deeplearningbook.org/

Support Vector Machines

scikit-learn Developers · 2026

https://scikit-learn.org/stable/modules/svm.html

Gaussian Processes for Machine Learning: Chapter 2, Regression

Carl Edward Rasmussen and Christopher K. I. Williams · 2006

https://gaussianprocess.org/gpml/chapters/RW2.pdf

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.