|
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 |
11 | 2 | """ |
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 |
16 | 9 | """ |
17 | 10 |
|
18 | 11 | 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 |
29 | 12 |
|
30 | 13 |
|
31 | 14 | def sigmoid_function(z: float | np.ndarray) -> float | np.ndarray: |
32 | 15 | """ |
33 | | - Also known as Logistic Function. |
| 16 | + Also known as the Logistic Function. |
34 | 17 |
|
35 | 18 | 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⁻ᶻ |
41 | 21 |
|
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. |
43 | 24 |
|
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. |
46 | 27 |
|
47 | 28 | Examples: |
48 | 29 | >>> float(sigmoid_function(4)) |
49 | 30 | 0.9820137900379085 |
50 | 31 | >>> sigmoid_function(np.array([-3, 3])) |
51 | 32 | 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]) |
62 | 33 | """ |
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)) |
64 | 36 |
|
65 | 37 |
|
66 | | -def cost_function(h: np.ndarray, y: np.ndarray) -> float: |
| 38 | +class LogisticRegression: |
67 | 39 | """ |
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. |
85 | 42 |
|
86 | 43 | 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 |
102 | 50 | """ |
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))) |
109 | 51 |
|
| 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([]) |
110 | 243 |
|
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]: |
129 | 244 |
|
130 | 245 | if __name__ == "__main__": |
131 | 246 | import doctest |
132 | 247 |
|
133 | 248 | doctest.testmod() |
134 | 249 |
|
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