Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions glassboxml/data/generators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions glassboxml/metrics/classification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import numpy as np

def accuracy(y_true, y_pred):
return np.mean(y_true == y_pred)
14 changes: 11 additions & 3 deletions glassboxml/models/decision_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -71,16 +76,19 @@ 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)

for f in range(n_features):
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):
Expand Down
37 changes: 20 additions & 17 deletions glassboxml/models/linear_regression.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions glassboxml/models/logistic_regression.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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']
Expand Down
5 changes: 3 additions & 2 deletions webapp/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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": {
Expand Down
Loading