diff --git a/glassboxml/data/generators.py b/glassboxml/data/generators.py index 8b2cd9d..92fc45a 100644 --- a/glassboxml/data/generators.py +++ b/glassboxml/data/generators.py @@ -29,16 +29,18 @@ def generate_regression_dataset( y : ndarray """ - np.random.seed(random_seed) + # A local generator (rather than np.random.seed, which mutates global state) + # keeps this call from affecting any other code relying on numpy's global RNG + rng = np.random.default_rng(random_seed) w_true = np.array(w_true) n_features = len(w_true) #Generates input values with normal distribution where mean = 0, variance = 1 - X = np.random.randn(n_samples, n_features) + X = rng.standard_normal((n_samples, n_features)) #Generates one noise value per sample - noise = np.random.normal(0, noise_std, size=n_samples) + noise = rng.normal(0, noise_std, size=n_samples) #Calculates true values of y: X multiplies true weights, @ is a matrix multiplication operator y = X @ w_true + b_true + noise @@ -74,16 +76,18 @@ def generate_classification_dataset( y : ndarray """ - np.random.seed(random_seed) + # A local generator (rather than np.random.seed, which mutates global state) + # keeps this call from affecting any other code relying on numpy's global RNG + rng = np.random.default_rng(random_seed) w_true = np.array(w_true) n_features = len(w_true) #Generates input values with normal distribution where mean = 0, variance = 1 - X = np.random.randn(n_samples, n_features) + X = rng.standard_normal((n_samples, n_features)) #Generates one noise value per sample - noise = np.random.normal(0, noise_std, size=n_samples) + noise = rng.normal(0, noise_std, size=n_samples) #Calculates true values of y: X multiplies true weights, @ is a matrix multiplication operator linear_output = X @ w_true + b_true + noise diff --git a/glassboxml/metrics/classification.py b/glassboxml/metrics/classification.py new file mode 100644 index 0000000..a398a57 --- /dev/null +++ b/glassboxml/metrics/classification.py @@ -0,0 +1,4 @@ +import numpy as np + +def accuracy(y_true, y_pred): + return np.mean(y_true == y_pred) diff --git a/glassboxml/models/decision_tree.py b/glassboxml/models/decision_tree.py index 2930c88..7560daa 100644 --- a/glassboxml/models/decision_tree.py +++ b/glassboxml/models/decision_tree.py @@ -34,6 +34,11 @@ def build_tree(self, X, y, depth): best_feature, best_threshold = self.find_best_split(X, y) + # No split improves purity (e.g. remaining samples have identical features + # but different labels) -> stop growing here instead of forcing a split + if best_feature is None: + return Leaf(y) + left_data, right_data, y_left, y_right = self.split(X, y, best_feature, best_threshold) left_child = self.build_tree(left_data, y_left, depth+1) @@ -71,8 +76,10 @@ def calculate_gini(self, y): def find_best_split(self, X, y): n_samples, n_features = X.shape best_IG = 0 - best_feature = 0 - best_threshold = 0 + # None signals "no split found" so build_tree can fall back to a leaf, + # instead of defaulting to an arbitrary (feature 0, threshold 0) split + best_feature = None + best_threshold = None gini_parent = self.calculate_gini(y) @@ -80,7 +87,8 @@ def find_best_split(self, X, y): values = np.unique(X[:, f]) thresholds = (values[:-1] + values[1:])/2 for t in thresholds: - left_idx = X[:,f] < t + # <= / > here must match the partition split() actually performs + left_idx = X[:,f] <= t right_idx = X[:,f] > t if (len(y[left_idx]) == 0 or len(y[right_idx]) == 0): diff --git a/glassboxml/models/linear_regression.py b/glassboxml/models/linear_regression.py index a359ec7..fbec7cc 100644 --- a/glassboxml/models/linear_regression.py +++ b/glassboxml/models/linear_regression.py @@ -14,29 +14,27 @@ def __init__(self, regularization=None): self.losses = [] pass - def compute_b(self, X, y): - b = np.mean(y) - self.w @ np.mean(X) - return b - - def compute_weight(self, X,y, pred_y): - # X is n by 1, y is n by 1, so (X - np.mean(X)).T @ (y - np.mean(y)) is 1 by 1, - # and np.sum((X - np.mean(X))**2) is a scalar, so the result is a scalar - w = (X - np.mean(X)).T @ (y - np.mean(y)) / np.sum((X - np.mean(X))**2) - return w - def predict(self, X): pred_y = X @ self.w + self.b return pred_y - + def fit_closed_form(self, X, y): - av_x = np.mean(X) + X = np.asarray(X) + y = np.asarray(y) + + # Per-column mean, so this centers every feature independently + # (np.mean(X) alone would collapse a multi-feature X to one scalar) + av_x = np.mean(X, axis=0) av_y = np.mean(y) - numerator = (X - av_x).T @ (y - av_y) - denominator = np.sum((X - av_x)**2) + X_centered = X - av_x + y_centered = y - av_y - self.w = numerator / denominator - self.b = av_y - self.w * av_x + # Centering X and y removes the need for a bias column; lstsq solves + # the normal equations without explicitly inverting X^T X, which is + # both faster and numerically safer than np.linalg.inv + self.w, *_ = np.linalg.lstsq(X_centered, y_centered, rcond=None) + self.b = av_y - self.w @ av_x def fit_gradient_descent(self, X, y, epochs, learning_rate): # Initialize weights and bias @@ -47,7 +45,12 @@ def fit_gradient_descent(self, X, y, epochs, learning_rate): for epoch in range(epochs): pred_y = self.predict(X) - self.losses.append(mse.mse_loss(y, pred_y)) + # Include the regularization penalty so self.losses reflects the + # actual objective being minimized, not just the MSE term + loss = mse.mse_loss(y, pred_y) + if self.regularization: + loss += self.regularization.loss(self.w) + self.losses.append(loss) # Calculate gradient for weight dw = (-2) / n_samples * X.T @ (y - pred_y) + (self.regularization.gradient(self.w) if self.regularization else 0) diff --git a/glassboxml/models/logistic_regression.py b/glassboxml/models/logistic_regression.py index c95e7e9..9b33d44 100644 --- a/glassboxml/models/logistic_regression.py +++ b/glassboxml/models/logistic_regression.py @@ -10,6 +10,9 @@ def __init__(self, regularization=None): pass def sigmoid(self, z): + # Clip to avoid `exp` overflow warnings on large-magnitude z; + # doesn't change the result since sigmoid saturates to 0/1 well before this + z = np.clip(z, -500, 500) return 1 / (1 + np.exp(-z)) def predict_proba(self, X): @@ -48,7 +51,11 @@ def fit(self, X, y, epochs, learning_rate): optimizer.step(params, grads) + # Include the regularization penalty so self.losses reflects the + # actual objective being minimized, not just the cross-entropy term loss = -np.mean(y * np.log(y_pred + self.epsilon) + (1 - y) * np.log(1 - y_pred + self.epsilon)) + if self.regularization: + loss += self.regularization.loss(self.w) self.losses.append(loss) self.w = params['w'] diff --git a/webapp/app.py b/webapp/app.py index baf3e83..53ea99b 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -10,6 +10,7 @@ generate_classification_dataset, generate_regression_dataset, ) +from glassboxml.metrics.classification import accuracy as accuracy_metric from glassboxml.metrics.regression import rmse as rmse_metric from glassboxml.models.decision_tree import DecisionTree from glassboxml.models.linear_regression import LinearRegression @@ -82,7 +83,7 @@ def _logistic_regression(req: RunRequest): model.fit(X, y, epochs=req.epochs, learning_rate=req.learning_rate) y_pred = model.predict(X) - accuracy = float(np.mean(y_pred == y)) + accuracy = float(accuracy_metric(y, y_pred)) # Decision boundary in 2D: w0*x0 + w1*x1 + b = 0 => x1 = -(w0*x0 + b) / w1 x0_range = np.linspace(float(X[:, 0].min()), float(X[:, 0].max()), 60) @@ -116,7 +117,7 @@ def _decision_tree(req: RunRequest): model.fit(X, y) y_pred = model.predict(X) - accuracy = float(np.mean(y_pred == y)) + accuracy = float(accuracy_metric(y, y_pred)) return { "scatter": {