Skip to content

Repository files navigation

RubyML

AtomicML - Learn machine learning from the code up

A small, didactic ML library in Ruby, built for learning. The goal is not to compete with production tools like scikit-learn; it is to see how ideas connect from scratch: vectors → losses → gradients → models → a tiny neural net. Every algorithm is written out explicitly so you can read exactly what happens inside — no black boxes, no magic imports, just plain code you can follow step by step.

Who this is for

  • Developers who already know Ruby (or any language) and want ML intuition + mechanics.
  • Anyone who asked: "What is actually happening inside model.fit?"

What you need first

  • Ruby and Bundler installed.
  • Comfort reading loops, arrays, and a little math notation (sums, slopes—nothing heavy).

How to use this README

  1. Read The learning path once (order matters).
  2. Run Setup so tests pass on your machine.
  3. Work top to bottom in Learn by phase: run the snippets in irb or small scripts.
  4. Use Classical models (cheat sheet) as a memory aid, not as your first read.
  5. Open model_stack_map.md when you wonder which model uses which loss or layer.

The learning path (recommended order)

Step Topic Idea you take away
1 Vector / Matrix Data and parameters are structured numbers; shapes must match.
2 MSE + SGD + LinearRegression A loss measures error; gradient descent nudges weights to reduce it.
3 LogisticRegression Same training loop, but outputs probabilities for classification.
4 KNN / KMeans Not everything is "train with gradients"—some models memorize or cluster.
5 NeuralNetwork (XOR) Non-linearity and layers let you solve what a single line cannot.
6 save / load A model is also data you can serialize.
7 (Optional) Web demo See training and state in a browser—nice capstone, not required to learn the core.
flowchart LR
  A[Vector Matrix] --> B[LinearRegression]
  B --> C[LogisticRegression]
  C --> D[KNN KMeans]
  D --> E[NeuralNetwork XOR]
  E --> F[save load]
Loading

Setup

bundle install

Run the full test suite:

bundle exec rspec -I lib

Learn by phase

Phase 1 — Vector and Matrix

Why start here? Almost every model multiplies numbers in bulk. If shapes are wrong, the bug is usually here.

irb -I lib
require "ruby_ml/vector"

v1 = RubyML::Vector.new([1, 2, 3])
v2 = RubyML::Vector.new([4, 5, 6])

v1.shape                 # => [3]
v1.transpose             # => [[1, 2, 3]]
v1.add(v2).to_a          # => [5, 7, 9]
v1.sub(v2).to_a          # => [-3, -3, -3]
v1.dot(v2)               # => 32
v1.mul_scalar(2).to_a    # => [2, 4, 6]

Vector × matrix multiplication

require "ruby_ml/vector"

vector = RubyML::Vector.new([2, 3])
matrix = [
  [1, 4, 7],
  [2, 5, 8]
]

vector.mul_matrix(matrix).to_a
# => [8, 23, 38]

Basic broadcasting (vector + matrix)

require "ruby_ml/vector"

vector = RubyML::Vector.new([10, 20, 30])
matrix = [
  [1, 2, 3],
  [4, 5, 6]
]

# axis: :row -> add per column (vector length == number of columns)
vector.broadcast_add_to_matrix(matrix, axis: :row)
# => [[11, 22, 33], [14, 25, 36]]

# axis: :column -> add per row (vector length == number of rows)
RubyML::Vector.new([10, 20]).broadcast_add_to_matrix(matrix, axis: :column)
# => [[11, 12, 13], [24, 25, 26]]

Matrix operations

require "ruby_ml/matrix"
require "ruby_ml/vector"

m1 = RubyML::Matrix.new([[1, 2, 3], [4, 5, 6]])
m2 = RubyML::Matrix.new([[10, 20, 30], [40, 50, 60]])

m1.shape                      # => [2, 3]
m1.transpose.to_a             # => [[1, 4], [2, 5], [3, 6]]
m1.add(m2).to_a               # => [[11, 22, 33], [44, 55, 66]]
m2.sub(m1).to_a               # => [[9, 18, 27], [36, 45, 54]]
m1.mul_scalar(2).to_a         # => [[2, 4, 6], [8, 10, 12]]

Matrix multiply and dot

require "ruby_ml/matrix"

a = RubyML::Matrix.new([[1, 2, 3], [4, 5, 6]])
b = RubyML::Matrix.new([[7, 8], [9, 10], [11, 12]])

a.mul_matrix(b).to_a
# => [[58, 64], [139, 154]]

a.dot(b).to_a
# => [[58, 64], [139, 154]]

Matrix + vector broadcasting

require "ruby_ml/matrix"
require "ruby_ml/vector"

matrix = RubyML::Matrix.new([[1, 2, 3], [4, 5, 6]])

# axis: :row -> vector length matches number of columns
matrix.broadcast_add_vector(RubyML::Vector.new([10, 20, 30]), axis: :row).to_a
# => [[11, 22, 33], [14, 25, 36]]

# axis: :column -> vector length matches number of rows
matrix.broadcast_add_vector([10, 20], axis: :column).to_a
# => [[11, 12, 13], [24, 25, 26]]

What to notice: shape and row/column counts are enforced—good errors save you hours.


Phase 2 — Linear regression (manual gradients)

Idea: predict ŷ ≈ xᵀw + b. The library computes gradients by hand (no autograd) so you can read every step.

require "ruby_ml"

x = (0..5).map { |i| [i.to_f] }
y = x.map { |row| (3.0 * row[0]) + 2.0 } # known line: y = 3x + 2

model = RubyML::Models::LinearRegression.new(learning_rate: 0.01, epochs: 1500)
model.fit(x, y)

model.weights       # ~ [3.0]
model.bias          # ~ 2.0
model.loss_history.first > model.loss_history.last  # loss should drop
model.predict([[6.0], [7.0]])                       # ~ [20.0, 23.0]

What to notice: loss_history tells a story—learning means the line fits the points better over time.


Phase 3 — Logistic regression, k-NN, k-means

Logistic regression — same training pattern as linear, but the output is a probability between 0 and 1, then a class using a threshold.

require "ruby_ml"

x = [[0.0], [0.1], [0.2], [0.8], [0.9], [1.0]]
y = [0, 0, 0, 1, 1, 1]

model = RubyML::Models::LogisticRegression.new(
  learning_rate: 0.5,
  epochs: 2000,
  threshold: 0.5
)

model.fit(x, y)
model.loss_history.first > model.loss_history.last # => true (downward trend)
model.predict_proba([[0.1], [0.9]])                # => [low_prob, high_prob]
model.predict([[0.1], [0.9]])                      # => [0, 1]
model.score(x, y)                                  # => accuracy

k-NN — stores training points; prediction = vote or average among nearest neighbors (no gradient loop).

require "ruby_ml"

x_train = [[0.0], [0.1], [0.2], [0.8], [0.9], [1.0]]
y_train = [0, 0, 0, 1, 1, 1]

classifier = RubyML::Models::KNN.new(k: 3, task: :classification)
classifier.fit(x_train, y_train)
classifier.predict([[0.05], [0.95]]) # => [0, 1]
classifier.score(x_train, y_train)   # => accuracy

x_reg = [[0.0], [1.0], [2.0], [3.0]]
y_reg = [0.0, 2.0, 4.0, 6.0]

regressor = RubyML::Models::KNN.new(k: 2, task: :regression)
regressor.fit(x_reg, y_reg)
regressor.predict([[1.5]])    # => [3.0] (neighbor average)
regressor.score(x_reg, y_reg) # => R²

k-means — groups points into clusters by moving centroids until assignments stabilize.

require "ruby_ml"

x = [
  [0.0, 0.0], [0.2, -0.1], [-0.1, 0.1],
  [10.0, 10.0], [9.8, 10.2], [10.1, 9.9]
]

model = RubyML::Models::KMeans.new(k: 2, max_iter: 100, tol: 1e-6)
model.fit(x)

model.centroids                            # => learned centroids
model.labels                               # => cluster id per training row
model.predict([[0.0, 0.1], [10.2, 10.1]]) # => cluster ids
model.score(x)                             # => inertia (lower is better)

What to notice: compare "optimize a loss with gradients" (logistic) vs "distance + rules" (k-NN, k-means).


Phase 4 — Neural network (XOR)

Idea: XOR is not linearly separable with one straight boundary. A small MLP with hidden units can learn it. Backprop is written explicitly—trace Dense, activations, and CrossEntropy in the code.

require "ruby_ml"

x = [[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]]
y = [0, 1, 1, 0]

model = RubyML::Models::NeuralNetwork.xor_default(learning_rate: 0.5)
model.fit(x, y, epochs: 8000)

model.loss_history.first > model.loss_history.last # => true (downward trend)
model.predict_proba(x)                             # => Nx1 probabilities
model.predict(x)                                   # => [0, 1, 1, 0] (expected)
model.score(x, y)                                  # => accuracy

Phase 5 — Perceptron (optional classic)

Linear classifier with a simple mistake-driven update. Good contrast with logistic regression's smooth loss.

require "ruby_ml"

# Logical OR (linearly separable)
x = [[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]]
y = [0, 1, 1, 1]

model = RubyML::Models::Perceptron.new(learning_rate: 0.1, epochs: 50)
model.fit(x, y)
model.predict(x)  # => [0, 1, 1, 1]
model.score(x, y) # => 1.0

Phase 6 — Save and load

Every model supports save(path) and .load(path) as JSON so you can treat a trained model like any other artifact.

require "ruby_ml"

x = [[0.0], [1.0], [2.0], [3.0]]
y = [1.0, 3.0, 5.0, 7.0]

model = RubyML::Models::LinearRegression.new(learning_rate: 0.01, epochs: 2000)
model.fit(x, y)
before = model.predict([[4.0]])

model.save("linear_model.json")
restored = RubyML::Models::LinearRegression.load("linear_model.json")
after = restored.predict([[4.0]])

before # ~= after

Browser demo (optional)

Lets you train, inspect state, and predict through HTTP—useful to connect "library code" to "something running."

bundle exec rackup --port 4567 -o 0.0.0.0

Open http://localhost:4567.

Main endpoints

  • GET /health
  • GET /grad — gradient descent visualization page
  • POST /train/:model
  • GET /state/:model
  • POST /predict/:model

Dynamic study mode (step-by-step training, adjustable learning_rate / threshold):

  • Supported models: linear_regression, logistic_regression
  • Session endpoints:
    • POST /session/start/:model
    • POST /session/step/:id
    • POST /session/update/:id
    • GET /session/state/:id
    • POST /session/predict/:id
    • POST /session/stop/:id
  • Flow: start session → toggle running (pause/resume) → advance step-by-step → adjust hyperparameters live

All models in the demo: linear_regression, logistic_regression, knn, kmeans, neural_network


Classical models (cheat sheet)

Model Main job Gradient-based? Historical note
Linear regression Predict continuous values Yes Legendre (1805), Gauss (1809)
Logistic regression Binary classification + probability Yes Berkson (1944)
k-NN Classify or regress by neighbors No (lazy learning) Fix & Hodges (1950s), Cover & Hart (1967)
k-means Unsupervised clusters No (assign / update) Lloyd (1957), MacQueen (1967)
Perceptron Binary linear boundary Rule-based updates Rosenblatt (1958)
Neural network Non-linear patterns (e.g. XOR) Yes

Dependency map (which file uses which loss/layer): model_stack_map.md.


Running tests

Full suite:

bundle exec rspec -I lib

Targeted runs:

# Vector / Matrix
bundle exec rspec -I lib spec/lib/ruby_ml/vector_spec.rb
bundle exec rspec -I lib spec/lib/ruby_ml/matrix_spec.rb

# Phase 2 — loss, optimizer, linear regression
bundle exec rspec -I lib spec/lib/ruby_ml/losses/mse_spec.rb spec/lib/ruby_ml/optimizers/sgd_spec.rb spec/lib/ruby_ml/models/linear_regression_spec.rb

# Phase 3 — logistic regression
bundle exec rspec -I lib spec/lib/ruby_ml/losses/binary_cross_entropy_spec.rb spec/lib/ruby_ml/models/logistic_regression_spec.rb

# k-NN
bundle exec rspec -I lib spec/lib/ruby_ml/models/knn_spec.rb

# k-means
bundle exec rspec -I lib spec/lib/ruby_ml/models/kmeans_spec.rb

Green tests mean the numerical behavior matches what the lesson expects—use them when you change code.


Model stack map

Quick reference for how library components couple together. Update this whenever you add a model, loss, optimizer, layer, or activation.

LinearRegression

  • file: lib/ruby_ml/models/linear_regression.rb
  • loss: RubyML::Losses::MSE
  • optimizer: RubyML::Optimizers::SGD
  • layers / activations: none

How it works: computes predictions as ŷ = w·x + b, measures error with MSE, then nudges w and b in the direction that reduces the loss (gradient descent). Each epoch is one full pass over the training data.

LogisticRegression

  • file: lib/ruby_ml/models/logistic_regression.rb
  • loss: RubyML::Losses::BinaryCrossEntropy
  • optimizer: RubyML::Optimizers::SGD
  • layers / activations: sigmoid lives inside the model (no separate module)

How it works: same gradient loop as linear regression, but squashes the raw score through a sigmoid so the output is always between 0 and 1. A threshold turns the probability into a class label.

KNN

  • file: lib/ruby_ml/models/knn.rb
  • loss: none
  • optimizer: none
  • layers / activations: none

How it works: no training phase at all — fit just stores the data. At predict time, it finds the k closest training points by Euclidean distance and returns the majority class (classification) or mean value (regression).

KMeans

  • file: lib/ruby_ml/models/kmeans.rb
  • loss: none (minimises inertia internally)
  • optimizer: none
  • layers / activations: none

How it works: Lloyd's algorithm. Start with k random centroids, assign each point to its nearest centroid, recompute each centroid as the mean of its assigned points, repeat until centroids stop moving. No labels required.

Perceptron

  • file: lib/ruby_ml/models/perceptron.rb
  • loss: none (classification error drives the update directly)
  • optimizer: none (internal perceptron rule)
  • layers / activations: step function inside the model

How it works: for each misclassified point, add or subtract the input from the weights. No loss function, no gradient — pure mistake-driven correction. Only converges if the data is linearly separable.

NeuralNetwork

  • file: lib/ruby_ml/models/neural_network.rb
  • layers: RubyML::Layers::Dense
  • activations: RubyML::Activations::ReLU, RubyML::Activations::Sigmoid
  • loss: RubyML::Losses::CrossEntropy
  • optimizer: RubyML::Optimizers::SGD

How it works: forward pass multiplies inputs through each Dense layer and activation in sequence. Backward pass propagates the loss gradient back through each layer (chain rule), and SGD adjusts the weights. The XOR default uses a 2 → 4 (ReLU) → 1 (sigmoid) architecture — the hidden layer introduces the non-linearity that makes XOR solvable.

Require hub

  • file: lib/ruby_ml.rb
  • role: single entry point that aggregates all requires for losses, optimizers, layers, activations, and models.

About

Learn how Machine Learning really works by building it from scratch in Ruby. Every model, loss, and optimizer implemented from first principles.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages