Skip to content

Commit c3f3427

Browse files
committed
Refactor logistic regression to OOP class with multi-class support
1 parent c0db072 commit c3f3427

1 file changed

Lines changed: 227 additions & 126 deletions

File tree

Lines changed: 227 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -1,159 +1,260 @@
1-
#!/usr/bin/python
2-
3-
# Logistic Regression from scratch
4-
5-
# In[62]:
6-
7-
# In[63]:
8-
9-
# importing all the required libraries
10-
1+
#!/usr/bin/python3
112
"""
12-
Implementing logistic regression for classification problem
13-
Helpful resources:
14-
Coursera ML course
15-
https://medium.com/@martinpella/logistic-regression-from-scratch-in-python-124c5636b8ac
3+
Implementing Logistic Regression (Binary, One-vs-Rest, and Softmax Multi-class)
4+
from scratch using NumPy.
5+
6+
References:
7+
- Wikipedia: https://en.wikipedia.org/wiki/Logistic_regression
8+
- Coursera Machine Learning Course by Andrew Ng
169
"""
1710

1811
import numpy as np
19-
from matplotlib import pyplot as plt
20-
from sklearn import datasets
21-
22-
# get_ipython().run_line_magic('matplotlib', 'inline')
23-
24-
25-
# In[67]:
26-
27-
# sigmoid function or logistic function is used as a hypothesis function in
28-
# classification problems
2912

3013

3114
def sigmoid_function(z: float | np.ndarray) -> float | np.ndarray:
3215
"""
33-
Also known as Logistic Function.
16+
Also known as the Logistic Function.
3417
3518
1
36-
f(x) = -------
37-
1 + e⁻ˣ
38-
39-
The sigmoid function approaches a value of 1 as its input 'x' becomes
40-
increasing positive. Opposite for negative values.
19+
f(z) = -------
20+
1 + e⁻ᶻ
4121
42-
Reference: https://en.wikipedia.org/wiki/Sigmoid_function
22+
The sigmoid function approaches a value of 1 as its input 'z' becomes
23+
increasingly positive, and approaches 0 as it becomes negative.
4324
44-
@param z: input to the function
45-
@returns: returns value in the range 0 to 1
25+
@param z: Input scalar or array to the function.
26+
@returns: Value(s) restricted in the range 0 to 1.
4627
4728
Examples:
4829
>>> float(sigmoid_function(4))
4930
0.9820137900379085
5031
>>> sigmoid_function(np.array([-3, 3]))
5132
array([0.04742587, 0.95257413])
52-
>>> sigmoid_function(np.array([-3, 3, 1]))
53-
array([0.04742587, 0.95257413, 0.73105858])
54-
>>> sigmoid_function(np.array([-0.01, -2, -1.9]))
55-
array([0.49750002, 0.11920292, 0.13010847])
56-
>>> sigmoid_function(np.array([-1.3, 5.3, 12]))
57-
array([0.21416502, 0.9950332 , 0.99999386])
58-
>>> sigmoid_function(np.array([0.01, 0.02, 4.1]))
59-
array([0.50249998, 0.50499983, 0.9836975 ])
60-
>>> sigmoid_function(np.array([0.8]))
61-
array([0.68997448])
6233
"""
63-
return 1 / (1 + np.exp(-z))
34+
z_clipped = np.clip(z, -500, 500) # Safe protection against exponent overflow
35+
return 1 / (1 + np.exp(-z_clipped))
6436

6537

66-
def cost_function(h: np.ndarray, y: np.ndarray) -> float:
38+
class LogisticRegression:
6739
"""
68-
Cost function quantifies the error between predicted and expected values.
69-
The cost function used in Logistic Regression is called Log Loss
70-
or Cross Entropy Function.
71-
72-
J(θ) = (1/m) * Σ [ -y * log(hθ(x)) - (1 - y) * log(1 - hθ(x)) ]
73-
74-
Where:
75-
- J(θ) is the cost that we want to minimize during training
76-
- m is the number of training examples
77-
- Σ represents the summation over all training examples
78-
- y is the actual binary label (0 or 1) for a given example
79-
- hθ(x) is the predicted probability that x belongs to the positive class
80-
81-
@param h: the output of sigmoid function. It is the estimated probability
82-
that the input example 'x' belongs to the positive class
83-
84-
@param y: the actual binary label associated with input example 'x'
40+
A robust Logistic Regression classifier supporting Binary, One-vs-Rest (OVR),
41+
and Softmax Multi-class classification using Mini-batch Gradient Descent.
8542
8643
Examples:
87-
>>> estimations = sigmoid_function(np.array([0.3, -4.3, 8.1]))
88-
>>> cost_function(h=estimations,y=np.array([1, 0, 1]))
89-
0.18937868932131605
90-
>>> estimations = sigmoid_function(np.array([4, 3, 1]))
91-
>>> cost_function(h=estimations,y=np.array([1, 0, 0]))
92-
1.459999655669926
93-
>>> estimations = sigmoid_function(np.array([4, -3, -1]))
94-
>>> cost_function(h=estimations,y=np.array([1,0,0]))
95-
0.1266663223365915
96-
>>> estimations = sigmoid_function(0)
97-
>>> cost_function(h=estimations,y=np.array([1]))
98-
0.6931471805599453
99-
100-
References:
101-
- https://en.wikipedia.org/wiki/Logistic_regression
44+
>>> clf = LogisticRegression(learning_rate=0.1, n_epochs=5, multi_class='binary')
45+
>>> mock_features = np.array([[1.0, 2.0], [2.0, 3.0], [3.0, 4.0], [4.0, 5.0]])
46+
>>> mock_targets = np.array([0, 0, 1, 1])
47+
>>> _ = clf.fit(mock_features, mock_targets)
48+
>>> len(clf.predict(mock_features))
49+
4
10250
"""
103-
return float((-y * np.log(h) - (1 - y) * np.log(1 - h)).mean())
104-
105-
106-
def log_likelihood(x, y, weights):
107-
scores = np.dot(x, weights)
108-
return np.sum(y * scores - np.log(1 + np.exp(scores)))
10951

52+
def __init__(
53+
self,
54+
learning_rate: float = 0.02,
55+
n_epochs: int = 200,
56+
multi_class: str = "binary",
57+
) -> None:
58+
self.learning_rate = learning_rate
59+
self.epochs = n_epochs
60+
self.weights: np.ndarray | None = None
61+
self.bias: float | np.ndarray | None = None
62+
self.multiclass = multi_class
63+
self.loss_history: list[float] = []
64+
self.classifiers: list["LogisticRegression"] | None = None
65+
66+
if self.multiclass not in ["binary", "ovr", "softmax"]:
67+
raise ValueError(
68+
"Incorrect class selection. Choose 'binary', 'ovr', or 'softmax'."
69+
)
70+
71+
def _softmax(self, z: np.ndarray) -> np.ndarray:
72+
"""Compute the softmax scaling values for each row of the matrix array."""
73+
exp_z = np.exp(z - np.max(z, axis=1, keepdims=True))
74+
return exp_z / np.sum(exp_z, axis=1, keepdims=True)
75+
76+
def _one_hot_encode(self, targets: np.ndarray, num_classes: int) -> np.ndarray:
77+
"""Transform numerical class vectors to a structural binary matrix."""
78+
y_hot_encode = np.zeros((len(targets), num_classes))
79+
y_hot_encode[np.arange(len(targets)), targets] = 1
80+
return y_hot_encode
81+
82+
def _softmax_loss(self, y_true: np.ndarray, y_pred: np.ndarray) -> float:
83+
"""Compute categorical cross-entropy loss metrics."""
84+
return float(-np.sum(y_true * np.log(y_pred)) / len(y_true))
85+
86+
def _compute_loss(self, y_true: np.ndarray, y_pred: np.ndarray) -> float:
87+
"""Compute binary cross-entropy log loss metrics."""
88+
return float(
89+
-np.mean(y_true * np.log(y_pred) + (1 - y_true) * np.log(1 - y_pred))
90+
)
91+
92+
def fit(self, features: np.ndarray, targets: np.ndarray) -> "LogisticRegression":
93+
"""Fit the model weights according to the specified multi_class parameters."""
94+
samples, total_features = features.shape
95+
batch_size = 32
96+
rng = np.random.default_rng()
97+
98+
if self.multiclass == "binary":
99+
targets_reshaped = targets.reshape(-1, 1)
100+
self.weights = rng.standard_normal((total_features, 1)) * 0.01
101+
self.bias = 0.0
102+
103+
for _ in range(self.epochs):
104+
indices = rng.permutation(samples)
105+
x_shuffled = features[indices]
106+
y_shuffled = targets_reshaped[indices]
107+
108+
num_batches = (samples + batch_size - 1) // batch_size
109+
converged = False
110+
111+
for i in range(num_batches):
112+
start_idx = i * batch_size
113+
end_idx = min((i + 1) * batch_size, samples)
114+
115+
x_batch = x_shuffled[start_idx:end_idx, :]
116+
y_batch = y_shuffled[start_idx:end_idx, :]
117+
118+
z = x_batch @ self.weights + self.bias
119+
y_pred = np.clip(sigmoid_function(z), 1e-15, 1 - 1e-15)
120+
121+
loss = self._compute_loss(y_batch, y_pred)
122+
self.loss_history.append(loss)
123+
124+
if (
125+
len(self.loss_history) > 2
126+
and abs(self.loss_history[-1] - self.loss_history[-2]) < 1e-6
127+
):
128+
converged = True
129+
break
130+
131+
up_bias = self.learning_rate * np.mean(y_pred - y_batch)
132+
up_weights = (
133+
self.learning_rate
134+
* (x_batch.T @ (y_pred - y_batch))
135+
/ len(y_batch)
136+
)
137+
138+
self.bias -= up_bias
139+
self.weights -= up_weights
140+
141+
if converged:
142+
break
143+
return self
144+
145+
elif self.multiclass == "ovr":
146+
self.classifiers = []
147+
for class_label in np.unique(targets):
148+
y_bin = np.where(targets == class_label, 1, 0)
149+
clf = LogisticRegression(
150+
learning_rate=self.learning_rate,
151+
n_epochs=self.epochs,
152+
multi_class="binary",
153+
)
154+
clf.fit(features, y_bin)
155+
self.classifiers.append(clf)
156+
return self
157+
158+
elif self.multiclass == "softmax":
159+
num_classes = len(np.unique(targets))
160+
self.weights = rng.standard_normal((total_features, num_classes)) * 0.01
161+
self.bias = np.zeros((1, num_classes))
162+
y_hot_encode = self._one_hot_encode(targets, num_classes)
163+
164+
for _ in range(self.epochs):
165+
indices = rng.permutation(samples)
166+
x_shuffled = features[indices]
167+
y_shuffled = y_hot_encode[indices]
168+
169+
num_batches = (samples + batch_size - 1) // batch_size
170+
converged = False
171+
172+
for i in range(num_batches):
173+
start_idx = i * batch_size
174+
end_idx = min((i + 1) * batch_size, samples)
175+
176+
x_batch = x_shuffled[start_idx:end_idx, :]
177+
y_batch = y_shuffled[start_idx:end_idx, :]
178+
179+
z = x_batch @ self.weights + self.bias
180+
y_pred = np.clip(self._softmax(z), 1e-15, 1 - 1e-15)
181+
182+
loss = self._softmax_loss(y_batch, y_pred)
183+
self.loss_history.append(loss)
184+
185+
if (
186+
len(self.loss_history) > 2
187+
and abs(self.loss_history[-1] - self.loss_history[-2]) < 1e-6
188+
):
189+
converged = True
190+
break
191+
192+
up_bias = self.learning_rate * np.mean(
193+
y_pred - y_batch, axis=0, keepdims=True
194+
)
195+
up_weights = (
196+
self.learning_rate
197+
* (x_batch.T @ (y_pred - y_batch))
198+
/ len(y_batch)
199+
)
200+
201+
self.bias -= up_bias
202+
self.weights -= up_weights
203+
204+
if converged:
205+
break
206+
return self
207+
208+
return self
209+
210+
def predict_proba(self, features: np.ndarray) -> np.ndarray:
211+
"""
212+
Return the calculated matrix vector distributions representing
213+
class probabilities.
214+
"""
215+
if self.multiclass == "binary":
216+
if self.weights is None or self.bias is None:
217+
raise ValueError("Model must be fitted before calling predict_proba.")
218+
z = features @ self.weights + self.bias
219+
return np.asarray(sigmoid_function(z))
220+
elif self.multiclass == "ovr":
221+
if self.classifiers is None:
222+
raise ValueError("Model must be fitted before calling predict_proba.")
223+
probs = np.column_stack(
224+
[clf.predict_proba(features) for clf in self.classifiers]
225+
)
226+
return probs
227+
elif self.multiclass == "softmax":
228+
if self.weights is None or self.bias is None:
229+
raise ValueError("Model must be fitted before calling predict_proba.")
230+
z = features @ self.weights + self.bias
231+
return self._softmax(z)
232+
233+
return np.array([])
234+
235+
def predict(self, features: np.ndarray) -> np.ndarray:
236+
"""Return clear label classifications vector maps across test arrays."""
237+
if self.multiclass == "binary":
238+
return (self.predict_proba(features) >= 0.5).astype(int).flatten()
239+
elif self.multiclass in ["ovr", "softmax"]:
240+
return np.argmax(self.predict_proba(features), axis=1)
241+
242+
return np.array([])
110243

111-
# here alpha is the learning rate, X is the feature matrix,y is the target matrix
112-
def logistic_reg(alpha, x, y, max_iterations=70000):
113-
theta = np.zeros(x.shape[1])
114-
115-
for iterations in range(max_iterations):
116-
z = np.dot(x, theta)
117-
h = sigmoid_function(z)
118-
gradient = np.dot(x.T, h - y) / y.size
119-
theta = theta - alpha * gradient # updating the weights
120-
z = np.dot(x, theta)
121-
h = sigmoid_function(z)
122-
j = cost_function(h, y)
123-
if iterations % 100 == 0:
124-
print(f"loss: {j} \t") # printing the loss after every 100 iterations
125-
return theta
126-
127-
128-
# In[68]:
129244

130245
if __name__ == "__main__":
131246
import doctest
132247

133248
doctest.testmod()
134249

135-
iris = datasets.load_iris()
136-
x = iris.data[:, :2]
137-
y = (iris.target != 0) * 1
138-
139-
alpha = 0.1
140-
theta = logistic_reg(alpha, x, y, max_iterations=70000)
141-
print("theta: ", theta) # printing the theta i.e our weights vector
142-
143-
def predict_prob(x):
144-
return sigmoid_function(
145-
np.dot(x, theta)
146-
) # predicting the value of probability from the logistic regression algorithm
147-
148-
plt.figure(figsize=(10, 6))
149-
plt.scatter(x[y == 0][:, 0], x[y == 0][:, 1], color="b", label="0")
150-
plt.scatter(x[y == 1][:, 0], x[y == 1][:, 1], color="r", label="1")
151-
(x1_min, x1_max) = (x[:, 0].min(), x[:, 0].max())
152-
(x2_min, x2_max) = (x[:, 1].min(), x[:, 1].max())
153-
(xx1, xx2) = np.meshgrid(np.linspace(x1_min, x1_max), np.linspace(x2_min, x2_max))
154-
grid = np.c_[xx1.ravel(), xx2.ravel()]
155-
probs = predict_prob(grid).reshape(xx1.shape)
156-
plt.contour(xx1, xx2, probs, [0.5], linewidths=1, colors="black")
157-
158-
plt.legend()
159-
plt.show()
250+
# Pure NumPy execution logic to ensure external packages like
251+
# sklearn aren't dependencies
252+
rng_test = np.random.default_rng(seed=42)
253+
sample_features = rng_test.standard_normal((100, 4))
254+
sample_targets = rng_test.choice([0, 1, 2], size=100)
255+
256+
model = LogisticRegression(learning_rate=0.05, n_epochs=50, multi_class="softmax")
257+
model.fit(sample_features, sample_targets)
258+
predictions = model.predict(sample_features)
259+
260+
print(f"Successfully tracked execution array shape output: {predictions.shape}")

0 commit comments

Comments
 (0)