Algorithm and implementation#
Logistic Regression formula is the same as the Linear Regression’s one passed to a sigmoid function to obtain a probability score from logits and can be replicated on numpy with:sigmoid(X @ w + b) (or sigmoid(np.dot(X,w)).
The implementation of gradient descent as an optimizer is the same of Linear Regression from scratch, what changes is the loss and the error since we deal with probabilities:
errors = probabilities - y- The loss is binary cross entropy:
$$BCE(y,p)=-[y\log(p)+(1-y)\log(1-p)], \qquad L=mean(BCE).$$ And it can be read as two cases:- when
y=1we have only-log(p) - when
y=0we have only-log(1-p)The loss is small when the correct outcome receives high probability.
The derivative is then: $$dw=X^T errors/N, \qquad db=mean(errors).$$
- when
- weights
wandbiasesare both initialized with zero values. After choosing a learning rate, at further stepswandbwill be updated as:w-=dw * learning_rateb-=db * learning_rate
Class structure#
- main attributes:
fitmethodpredict_probamethodpredictmethod_sigmoidstatic method (it uses the@staticmethoddecorator, that simply allows us to not include theself, since this function does not depend on anyself.parameter)_binary_cross_entropymethod
import numpy as np
class LogisticRegression:
def __init__(self, learning_rate=0.05, n_iterations=1000):
self.learning_rate = learning_rate
self.n_iterations = n_iterations
self.w_ = None
self.b_ = None
def fit(self, X, y):
pass
def predict_proba(self, X):
pass
@staticmethod
def _sigmoid(score):
pass
@staticmethod
def _binary_cross_entropy(probabilities, y, epsilon):
pass
def predict(self, X):
passSigmoid#
To pass from logits to probabilities, we need the sigmoid function to transform the linear score.
Its base implementation is just as follows:
@staticmethod
def _sigmoid(scores):
return 1/(1+np.exp(-scores))This formula is correct for every real number, but a computer has a largest representable floating-point value that can lead to overflow.
If z = -1000, the direct formula asks NumPy to compute exp(1000), which is too large.
The final sigmoid should simply be very close to zero, so an intermediate overflow is unnecessary.
We choose the formula according to the sign of z so the value passed to exp is always zero or negative:
- for non-negative score (
z >= 0) we keep the standard formula. Here-z <= 0, soexp(-z)is safe. $$sigmoid(z)=\frac{1}{1+e^{-z}}.$$ - for negative score (
z < 0): rearrange the same formula by multiplying its numerator and denominator byexp(z): $$\frac{1}{1+e^{-z}}\times\frac{e^z}{e^z}=\frac{e^z}{1+e^z}.$$Now, ifz = -1000, we computeexp(-1000), which safely becomes approximately zero. The probability also becomes approximately zero. We never compute the dangerousexp(1000).
The two branches are not two different sigmoid functions. They are two algebraically equivalent ways to calculate the same function, selected only to keep the intermediate exponential safe.
@staticmethod
def _sigmoid(scores):
#create an array with the desired shape filled with zeros
scores = np.asarray(scores, dtype=float)
probabilities = np.zeros_like(scores)
#create the branch filters
nonnegative = scores >= 0
negative = scores < 0
#fill the probabilities array from the positive branch
probabilities[nonnegative] = (1.0/ (1.0 + np.exp(-scores[nonnegative])))
#fill from the negative branch
exp_scores = np.exp(scores[negative])
probabilities[negative] = exp_scores / (1.0 + exp_scores)
return probabilitiesBinary cross-entropy#
Binary cross-entropy clips probabilities, calculates the positive-label and negative-label terms, adds them and averages.
@staticmethod
def _binary_cross_entropy(probabilities, y, epsilon=1e-12):
clipped_probabilities = np.clip(probabilities, epsilon, 1.0 - epsilon)
positive_label_loss = -y * np.log(clipped_probabilities)
negative_label_loss = -(1.0 - y) * np.log(1.0 - clipped_probabilities)
per_example_loss = positive_label_loss + negative_label_loss
loss = np.mean(per_example_loss)
return lossTraining#
Similar to Linear Regression from scratch, the fit method can be implemented following these steps:
- input shapes setup with
Xof shape(N,D)andyof shape(N,) - weights and bias initialization
- “SPELGU loop”:
- Score:
score = X @ self.w_ + self.b_(shape(N,)) - Probabilities:
self._sigmoid(score)(shape(N,)) - Errors:
errors = probabilities - y(shape(N,)) - Loss:
loss = binary_cross_entropy(probabilities, y)(scalar). - Gradient:
dw = X.T @ errors / n_samples(shape(D,))db = errors.mean()(scalar)
- Update:
self.w_ = self.learning_rate * dw(shape(D,))self.b_ = self.learning_rate * db(scalar)
- Score:
def fit(self, X, y):
X = np.asarray(X, dtype=float)
y = np.asarray(y, dtype=float)
if X.ndim != 2 or y.ndim != 1 or len(X) == 0 or len(X) != len(y):
raise ValueError("X must be (N,D) and y must be (N,), with matching N > 0")
n_samples, n_features = X.shape
self.w_ = np.zeros(n_features)
self.b_ = 0.0
self.loss_history_ = []
for _ in range(self.n_iterations):
score = X @ self.w_ + self.b_ # S: (N,)
probabilities = self._sigmoid(scores) # P: (N,)
errors = probabilities - y # E: (N,)
loss = self._binary_crossentropy(probabilities, y) # L: scalar
dw = X.T @ errors / n_samples # G: (D,)
db = errors.mean() # G: scalar
self.w_ -= self.learning_rate * dw # U: (D,)
self.b_ -= self.learning_rate * db # U: scalar
self.loss_history_.append(float(loss))
return selfInference (probability)#
The prediction of the probability can be implemented as:
def predict_proba(self, X):
if self.w_ is None:
raise RuntimeError("fit must be called before predict")
X = np.asarray(X, dtype=float)
scores = X @ self.w_ + self.b_
probabilities = self._sigmoid(scores)
return probabilitiesInference (label)#
In case we need a label prediction and not a probability prediction, we use the predict method setting up a threshold:
def predict(self, X, threshold=0.5):
probabilities = self.predict_proba(X)
labels = (probabilities >= threshold).astype(int)
return labelsFeatures scaling#
To make learning more robust, Standardization can be used.
Adding regularization#
Similar to Linear Regression, adding regularization is a common step to tackle situations in which a model can fit training noise with unnecessarily large weights (overfitting), especially when features are correlated or data is limited.
We can penalize the model for large weights adding a sort of “penalty” to the loss we saw in the base implementation:regularization_loss = 0.5 * l2 * sum(w ** 2)
Its gradient is regularization_dw = l2 * w, which pulls every weight toward zero, with l2 controlling the “strength of the pull”.
This affects the total loss and the weights gradient dw. Bias b does not need to be regularized because it does not measure the dependence on a feature.
This step usually comes after scaling.
The fit method of the LogisticRegression class would look like:
def fit(self, X, y, l2):
X = np.asarray(X, dtype=float)
y = np.asarray(y, dtype=float)
if X.ndim != 2 or y.ndim != 1 or len(X) == 0 or len(X) != len(y):
raise ValueError("X must be (N,D) and y must be (N,), with matching N > 0")
n_samples, n_features = X.shape
self.w_ = np.zeros(n_features)
self.b_ = 0.0
self.loss_history_ = []
for _ in range(self.n_iterations):
score = X @ self.w_ + self.b_ # S: (N,)
probabilities = self._sigmoid(scores) # P: (N,)
errors = probabilities - y # E: (N,)
data_loss = self._binary_crossentropy(probabilities, y) # L: scalar
regularization_loss = 0.5 * l2 * np.sum(self.w_ ** 2)
loss = data_loss + regularization_loss
data_dw = X.T @ errors / n_samples # G: (D,)
regularization_dw = l2 * self.w_
dw = data_dw + regularization_dw
db = errors.mean() # G: scalar
self.w_ -= self.learning_rate * dw # U: (D,)
self.b_ -= self.learning_rate * db # U: scalar
self.loss_history_.append(float(loss))
return selfAdding early-stopping#
When an algorithm converges, further iterations barely change the parameters. A fixed iterations count is safe but it may waste work. The idea behind early stopping is to stop when the largest absolute update of w and b is below tolerance.
If, on the contrary, learning rate diverges we must stop the training as we are likely finding nulls in the loss.
The implementation is the same as the one used in Linear Regression from scratch.